Eine Tetris-Anwendung auf Basis moderner Entwurfs- und Realisierungskonzepte

1. Aufgabe

In dieser Fallstudie betrachten wir das bekannte Computerspiel Tetris. Die Spielregeln sind nicht sonderlich schwer. Es wird auf einem rechteckigen Spielfeld gespielt, auf dem sich eine zufällig erzeugte, aus vier Kästchen zusammengesetzte, Spielfigur (so genanntes Tetrimino) nach unten bewegt. Die Spielfigur kann während des Spiels im Gegenuhrzeigersinn in 90°-Grad-Schritten gedreht oder aber nach links oder rechts bewegt werden (sofern Platz auf dem Spielfeld ist). Ist die Spielfigur unten angekommen, bleibt sie dort liegen und das Programm erzeugt die nächste Spielfigur (zufällig aus einer Liste von sieben möglichen Tetriminos), welche wieder von oben nach unten fällt. Aufgabe des Spielers ist es zu versuchen, mit den Tetriminos am unteren Spielfeldrand eine möglichst lückenlose Reihe zu bilden. Sobald eine solche Reihe entstanden ist, wird diese vom Spielbrett entfernt und die darüberliegenden Spielsteine rutschen automatisch eine Zeile nach unten. Ziel des Spiels ist es, möglichst viele dieser Reihen zu erzeugen. Beendet ist das Spiel, wenn auf Grund von ungeschickten Platzierungen keine lückenlosen Reihen gebildet werden können und auf diese Weise keine Punkte mehr erzielbar sind.

Neben der Umsetzung der Spielidee in eine C#-Xamarin-App ist es Ziel dieser Aufgabe, moderne Entwurfskonzepte in der SW-Konzeption zu Grunde zu legen, wie zum Beispiel die Anwendung eines Entwurfsmusters oder der Einsatz eines Dependency Injection-Containers.

1.1. Entwurfsgrundlagen der Tetris-Anwendung

In diesem Abschnitt legen wir einige konzeptionelle Grundlagen für den Entwurf der Tetris-Anwendung. Die Maße eines Tetris-Spielfelds sind in der Regel mit 20 Reihen und 10 Spalten standardisiert. Trotzdem wäre es – etwa zum Testen der Anwendung während der Entwicklungsphase – wünschenswert, die Maße des Spielfelds mit zwei Eigenschaften NumRows und NumColumns variabel zu halten.

Während des Spielablaufs kann das Spiel eine Reihe von Zuständen einnehmen. Wenn wir dabei noch ins Kalkül ziehen, dass zwischen zwei Spielabläufen der Wechsel zu einer HighScore-Ansicht möglich sein soll, also das eigentliche Tetris-Spiel pausiert, lassen sich mit dem Aufzählungstyp GameState alle möglichen Spielzustände wie folgt beschreiben (Listing 1):

public enum GameState { GameIdle, GameRunning, GamePaused, GameOver }

Beispiel 1. Aufzählungstyp GameState.


Gehen wir etwas näher auf den Zustand GameRunning ein, in Abbildung 1 der Einfachheit halber als Running bezeichnet:

Zustände eines Tetris-Spiels.

Abbildung 1. Zustände eines Tetris-Spiels.


Von Spielschritt zu Spielschritt bewegt sich ein Tetrimino von oben nach unten. Teile des Spielfelds sind folglich pro Spielschritt neu zu zeichnen. Der sehr triviale Ansatz, alle 200 Kästchen des Spielfeldes neu zu zeichnen, wäre aus Performance-Gründen nicht einmal auf einem Standard-PC mit guter HW-Ausstattung machbar geschweige denn auf einem Smartphone-Device mit dedizierten Resourcen. Aus diesem Grund definieren wir für die Änderungen eines Spielschritts eine Klasse ViewCellList. Objekte dieses Listentyps enthalten eine Reihe von ViewCell-Objekten (Listing 2):

1: public class ViewCell
2: {
3:     // properties
4:     public CellColor Color { get; set; }
5:     public CellPoint Point { get; set; }
6:     ...
7: }

Beispiel 2. Klasse ViewCell.


ViewCell-Objekte beschreiben ein einzelnes Spielfeldelement (Kästchen) mit seiner Position und seinem Aussehen (Farbe). Auf diese Weise lässt sich das Herunterfallen einer Spielfigur grafisch auf diejenigen Spielfeldelemente reduzieren, die bei einer Bewegung tatsächlich neu gezeichnet werden müssen.

Objekte des Typs ViewCell bestehen also aus zwei Variablen des Typs CellPoint und CellColor. CellPoint definiert einen C#-Strukturtyp, um die Position eines einzelnen Kästchens eines Tetriminos festzulegen (Listing 3):

public struct CellPoint
{
    public int X { get; set; }
    public int Y { get; set; }
}

Beispiel 3. Strukturtyp CellPoint.


Die Farben eines einzelnen Tetriminos sind im Original ebenfalls festgelegt. Es genügt daher, in Listing 4 einen Aufzählungstyp zu Rate zu ziehen:

public enum CellColor { LightGray, Red, Yellow, Magenta, Green, Blue, Ocker, Cyan }

Beispiel 4. Aufzählungstyp CellColor.


Wir kommen jetzt auf die ITetris-Schnittstelle zu sprechen. In der Betrachtung des dynamischen Ablauf des Spiels sollte man dieses „starten“, „stoppen“ bzw. „anhalten“ („pausieren“) und anschließend wieder „fortfahren“ können. Möchte man nach dem Anhalten eines Spiels dieses nicht fortsetzen, bietet sich ein „Löschen“ des Spielfelds an. Damit sollten die Schnittstellenmethoden Start, Stop, Pause, Continue und Clear verständlich sein.

Neben den zuvor angesprochenen Methoden, die den Ablauf des Spiels beeinflussen, gibt es aber auch Methoden, die sich auf die Bewegung eines einzelnen Tetriminos auswirken. Diese beschreiben wir am besten wieder mit einem Aufzählungstyp:

public enum TetriminoAction { None, Left, Right, Rotate, BeginAllWayDown, EndAllWayDown }

Beispiel 5. Aufzählungstyp TetriminoAction.


Hinweis: Die beiden Elemente BeginAllWayDown und EndAllWayDown sind nicht ganz selbsterklärend bzw. sie resultieren aus dem von mir eingeschlagenen Realisierungsweg. Da sich die Geschwindigkeit eines Tetriminos bei dem Bewegungskommando „Gehe nach unten“ erhöht (und diese unten wieder auf Normalgeschwindigkeit angepasst werden muss), habe ich aus diesem Grund zwei Aufzählungselemente definiert. Sicherlich kann man dies auch anders konzipieren, ich würde die zum jetzigen Entwurfszeitpunkt als „Detail“ einordnen.

Für die Menge aller Tetrimino-Bewegungen bietet sich eine „Sammel“-Methode an, etwa der Gestalt DoAction:

void DoAction(TetriminoAction action);

Diese Methode ist für den Fall konzipiert, dass eine View direkt ein Bewegungskommando an die Spiellogik weiterreichen kann. Das Erkennen, welche Aktitivität ein Tetrimino ausführen soll, ist nicht immer ganz so einfach. Bei einer klassischen Tetris-Spielekonsole werden Bedienungen durch entsprechende Bedienelemente (Knöpfe) mit Hardware-Unterstützung einfach erkannt und dementsprechend direkt an die Software weitergereicht. Bei einer „Software“-Konsole wie etwa einem Smartphone verhält es sich anders. Hier kann man Berührungsgestiken in einer Ansicht (View) auswerten, die auf Grund ihrer Örtlichkeit aber erst von der Spielelogik einer konkreten Folgehandlung zugeordnet werden müssen. Berührt der Spieler ein laufendes Tetris-Spiel etwa mit einer „Touch“-Geste am linken oder rechten Spielfeldrand, könnte er eine Left- oder Right-Aktivität auslösen wollen. Befindet sich unmittelbar an der Stelle der Berührungsgeste ein aktives Tetrimino, würde man dieser Geste eine Rotate-Aktivität zuordnen. Eine nach unten gerichtete Wischgeste wiederum könnte bedeuten, dass der Spieler das aktive Tetrimino schneller nach unten verschieben möchte. Dies setzt aber voraus, dass zum Zeitpunkt der Wischgeste überhaupt ein aktives Tetrimino im Spiel ist.

Aus diesem Grund überladen wir die DoAction-Methode in einer zweiten Variante:

bool DoAction (int row, int col);

In diesem Fall muss das Modell selbst entscheiden, welche Aktivität (wenn überhaupt) zu veranlassen ist. Neben den Eigenschaften und Methoden einer Schnittstelle (bzw. einer Klasse, die eine bestimmte Schnittstelle implementiert), ist auch der Aspekt zu beachten, wenn sich der Zustand eines Objekts ändert. Die allgemeine Antwort darauf bietet das C#-Ereigniskonzept. Da wir die gesamte Tetris-Anwendung mit dem MVVM-Entwurfsmuster realisieren werden (dazu später noch mehr), haben wir es deshalb mit der INotifyPropertyChanged-Schnittstelle zu tun:

public interface INotifyPropertyChanged
{
    event PropertyChangedEventHandler PropertyChanged;
}

Der delegate-Typ PropertyChangedEventHandler ist dabei wie folgt definiert:

public delegate void PropertyChangedEventHandler(Object sender, PropertyChangedEventArgs e);

Wann immer sich Eigenschaften eines bestimmten Objekts ändern, können sich Clients durch das Auslösen des PropertyChanged-Ereignisses darüber informieren lassen. Im Parameter vom Typ PropertyChangedEventArgs wird die Information übertragen, welche Eigenschaft ihren Wert geändert hat (Eigenschaft PropertyName). In einem nachgelagerten Schritt kann der Client dann den neuen Eigenschaftswert anfordern.

1.2. Schnittstelle ITetris

Das interface-Konzept moderner Programmiersprachen wie Java und C# ermöglicht es, beim Entwurf der Klassen eine Abstraktionsschicht einzuziehen. In unserem Beispiel bietet sich eine Schnittstelle ITetris an, um den generellen Ablauf eines Tetris-Spiels zu verallgemeinern. An Hand der Vorbereitungen aus dem letzten Abschnitt könnte eine ITetris-Schnittstelle wie in Listing 6 gezeigt aussehen:

01: public interface ITetris : INotifyPropertyChanged
02: {
03:     // properties
04:     int NumRows { get; }
05:     int NumColumns { get; }
06:     GameState GameState { get; }
07:     ViewCellList BoardStateChanges { get; }
08: 
09:     // game commands
10:     void Start();
11:     void Stop();
12:     void Pause();
13:     void Continue();
14:     void Clear();
15: 
16:     // tetrimino commands
17:     void DoAction(TetriminoAction action);
18:     bool DoAction(int row, int col);
19: }

Beispiel 6. Konzeption der Schnittstelle ITetris.

1.3. Schnittstelle ITetrimino

Im Verlauf eines Tetris-Spiel kommt einer einzelnen Tetrimino-Spielfigur eine besondere Rolle zu. Auf dem Spielfeld darf das Tetrimino nur dort platziert werden, wo noch Platz frei ist. Zusätzlich kann ein Tetrimino durch Aktivitäten des Spielers auch beeinflusst werden. Rotationen, Bewegungen nach links oder rechts und eine Beschleunigung des vertikalen Bewegungsverlaufs können ausgelöst werden. Neben dem aktiven Tetrimino spielen auch die passiven Tetriminos eine Rolle. Zum einen belegen sie das Spielfeld, sprich sie blockieren das aktive Tetrimino in seiner Bewegungsfreiheit. Zum anderen muss das Spiel pro Bewegung überprüfen, ob die passiven Tetriminos die unterste Reihe (oder mehrere untere Reihen) komplett belegen. In diesem Fall sind eine oder mehrere Reihen am unteren Spielfeldrand zu entfernen und der Spieler bekommt dafür Pluspunkte gutgeschrieben.

Der nachfolgende Schnittstellentyp ITetrimino (siehe Listing 7) versucht, einige der Anforderungen an eine Tetrimino-Spielfigur mit geeigneten Schnittstellendeklarationen zu erfassen:

01: public interface ITetrimino
02: {
03:     // predicates
04:     bool CanSetToTop();
05:     bool CanMoveLeft();
06:     bool CanMoveRight();
07:     bool CanMoveDown();
08:     bool CanRotate();
09:     bool IsCoordinateWithin (int row, int col);
10: 
11:     // movement specific methods
12:     void SetToTop();
13:     void MoveLeft();
14:     void MoveRight();
15:     bool MoveDown();
16:     void Rotate();
17: 
18:     // board specific methods
19:     void Set();
20:     void Delete();
21: }

Beispiel 7. Konzeption der Schnittstelle ITetrimino.


Bevor man ein Tetrimino auf dem Spielfeld nach unten oder zur Seite bewegt, ist zu überprüfen, ob der Platz dafür ausreicht. Dazu gibt es die fünf Methoden CanSetToTop, CanMoveLeft, CanMoveRight, CanMoveDown und CanRotate. Lässt sich eine Bewegung durchführen, sollte das Tetrimino-Objekt dann zu diesem Zweck eine der Methoden SetToTop, MoveLeft, MoveRight, MoveDown oder Rotate verwenden.

Das Bewegen eines Tetriminos kann man in zwei Phasen unterteilen. Vor dem Bewegen sollte man alle von dem Tetrimino belegten Kästchen „Löschen“, also in den Ausgangszustand versetzen. Die neue Position des Tetriminos verändert ebenfalls eine Reihe von anderen Kästchen. Zu diesem Zweck gibt es zwei Methoden Set und Delete, um das Tetrimino zu Löschen und neu zu platzieren.

1.4. Schnittstelle ITetrisBoard

Das Spiel selbst, also das Bewegen eines einzelnen Tetriminos, erfolgt auf einem Spielfeld. Zu diesem Zweck konzipieren wir eine Schnittstelle ITetrisBoard:

01: public interface ITetrisBoard
02: {
03:     event BoardChangedHandler BoardChanged;
04: 
05:     // properties
06:     int NumRows { get; }
07:     int NumColumns { get; }
08: 
09:     // indexer
10:     TetrisCell this[int row, int col] { get; set; }
11: 
12:     // methods
13:     void Clear();
14:     void PostChanges(ViewCellList list);
15:     bool IsBottomRowComplete();
16:     void MoveNonEmptyRowsDown();
17: }

Beispiel 8. Konzeption der Schnittstelle ITetrisBoard.


Objekte, die die ITetrisBoard-Schnittstelle implementieren, verwalten offenbar ein Feld von Zellen, die wir als Objekte der Klasse TetrisCell betrachten (Listing 9):

1: public struct TetrisCell
2: {
3:     // properties
4:     public CellState State { get; set; }
5:     public CellColor Color { get; set; }
6:     ...
7: }

Beispiel 9. Struktur TetrisCell.


Einer Zelle sind ein Zustand und eine Farbe zugeordnet. Die Farbe, die einer Zelle zugeordnet ist, dient zur Visualisierung der Zelle. Der Aufzählungstyp CellColor eignet sich für diesen Zweck, wir haben ihn in Listing 4 bereits angesprochen.

Der Zellzustand (Aufzählungstyp CellState) kann einen der beiden Werte Free oder Used annehmen, siehe dazu die Festlegungen in Tabelle 1:

Element

Beschreibung

Zustand Free

Spielfeldzellen im Zustand Free sind frei. Weder das aktuell in Bewegung befindliche Tetrimino noch die am unteren Spielfeldrand angekommenen Tetriminos belegen diese Zelle.

Zustand Used

Spielfeldzellen, die von einem Tetrimino belegt sind, ist der Zellzustand Used zugewiesen. Dabei spielt es keine Rolle, ob das Tetrimino noch in Bewegung ist (aktiv) oder bereits am unteren Spielfeldrand angekommen ist (inaktiv). Zellen mit dem Zustand Used dürfen von einem anderen Tetrimino nicht betreten werden.

Tabelle 1. Aufzählungstyp CellState.


Legen Sie in Ihrer Realisierung einer Klasse TetrisBoard (die natürlich die ITetrisBoard-Schnittstelle implementiert) ein zwei-dimensionales Array an, dessen Elemente Objekte des Typs TetrisCell sind. Um komfortabel auf Zellenobjekte zugreifen zu können, besitzen diese zwei Eigenschaften State und Color (Tabelle 2):

Element

Beschreibung

Eigenschaft State

public CellState State { get; set; }

Dient zum Lesen und Schreiben des Zellenzustands (wie in Tabelle 1 beschrieben).

Eigenschaft Color

public CellColor Color { get; set; }

Dient zum Lesen und Schreiben der Zellenfarbe (wie in Listing 4 beschrieben).

Tabelle 2. Öffentliche Elemente der Klasse TetrisCell.


Neben den Zellen des Spielfelds bietet die Klasse TetrisBoard auch eine Reihe von Methoden an, die vor allem dann eine Rolle spielen, wenn ein aktives Tetrimino passiv wird, also nicht mehr bewegt werden kann. Am unteren Rand des Spielfelds gilt es nun zu überprüfen, ob sich lückenlose Reihen gebildet haben. Hierzu finden Sie einige Vorschläge für Methodendefinitionen in Tabelle 3 vor:

Methode

Beschreibung

Methode IsBottomRowComplete

public bool IsBottomRowComplete();

Überprüft, ob die unterste Reihe des Spielfelds lückenlos von Tetriminos belegt ist.

Methode CopyBlocksOneRowDown

public void CopyBlocksOneRowDown();

Kopiert alle nichtleeren, am unteren Spielfeldrand befindlichen Reihen um eine Reihe nach unten. Siehe dazu auch die nachfolgende Hilfsmethode IsRowEmpty.

Methode IsRowEmpty

private bool IsRowEmpty(int index);

Überprüft, ob eine Reihe des Spielfelds (Parameter index) leer ist (also alle Zellen den Zellzustand CellState.Free haben), oder aber mindestens eine Zelle den Zustand CellState.Used besitzt.

Tabelle 3. Öffentliche Methoden der Klasse TetrisBoard.

1.5. Objektorientierter Klassenentwurf

Speziell im Umfeld der ITetrimino-Schnittstelle lassen sich die Prinzipien des objektorientierten Entwurfs recht gut zur Anwendung bringen. Ausgehend vom Design der ITetrimino-Schnittstelle kann man Überlegungen anstellen, welche der Schnittstellenmethoden ausschließlich von konkreten Tetriminos realisierbar sind und welche von allen Tetriminos gemeinsam genutzt werden können.

In meiner konkreten Realisierung sieht es zum Beispiel so aus, dass die Prädikatsmethoden (CanSetToTop, CanMoveLeft, CanMoveRight, CanMoveDown und CanRotate) das Wissen über das genaue Aussehen ihres zugeordneten Tetriminos besitzen müssen. Aus diesem Grund sind sie in den abgeleiteten Klassen wie etwa Tetrimino_O, Tetrimino_L, etc. implementiert. Die Bewegungsmethoden hingegen (SetToTop, MoveLeft, MoveRight, MoveDown und Rotate) lassen sich allgemein implementieren (vorausgesetzt natürlich, eine entsprechende Prädikatsmethode Can... hat zuvor ihr Einverständnis gegeben). Ihre Implementierung könnte folglich in einer abstrakten Basisklasse Tetrimino angesiedelt werden.

Die Mechanismen des objektorientierten Klassenentwurfs wie etwa Schnittstelle, abstrakte Basisklasse und abgeleitete Spezialisierungen finden Sie allesamt in Abbildung 2 vor:

Klassendiagramm für Tetriminos.

Abbildung 2. Klassendiagramm für Tetriminos.

1.6. Entwurfsmuster MVVM

Die Model-View-ViewModel (MVVM) Entwurfsrichtlinie hat sich zum Entwurfsmuster schlechthin für jede Wahl von Xamarin-App entwickelt. Über das Entwurfsmuster lassen sich im Internet zahlreiche Beschreibungen nachlesen, wir wollen im Folgenden nur einen knappen Überblick geben, siehe dazu auch Abbildung 3.

Model-View-ViewModel Entwurfsmuster.

Abbildung 3. Model-View-ViewModel Entwurfsmuster.


Im Wesentlichen besteht das MVVM-Entwurfsmuster aus drei Bestandteilen:

  • Modell – Komponente, die die Daten der Anwendung enthält.

  • Views (Ansichten) – Eine oder mehrere Komponenten, die die Daten der Anwendung (Modell) grafisch visualisieren.

  • ViewModel – Komponente, die das Modell und die Sicht(en) mit spezifischen Framework-Mechanismen (Kommandos mit Standardschnittstelle ICommand, Datenbindung auf Basis von Standardschnittstelle INotifyPropertyChanged) zusammenführt.

Durch die Trennung des Modells von den Sichten kann man beispielsweise den Logikteil einer Anwendung, der sich im ViewModel zusammen mit dem unterlagerten Modell befindet, separat testen. Ein weiteres Charakteristikum des MVVM-Entwurfsmusters besteht darin, dass eine Sicht ihr ViewModel und ein ViewModel sein Modell kennt, aber der Blick in die entgegengesetzte Richtung nicht möglich ist. Auf diese Weise wird verhindert, dass unerwünschte Abhängigkeiten innerhalb einer Anwendung entstehen.

Dazu zählt eine weitere Vorgabe, nämlich dass Views – von Ausnahmen abgesehen – keinerlei programmiersprachliche Anweisungen besitzen sollten. Sie sind – zumindest der reinen Lehre nach – ausschließlich mit deklarativen Mitteln (XAML) erstellt. Dadurch kann man sicherstellen, dass sich nicht (gewollt oder ungewollt) Logikteile der Anwendung in den View-Anteil verlagern. Dies würde wiederum die automatisierte Testbarkeit einer Anwendung (ohne Views / ohne UI-Thread) außer Kraft setzen würden.

1.7. Ansicht der Tetris-Anwendung

Wir konzipieren die grafische Ansicht unserer Tetris-Anwendung wie folgt: Es sind – in Abhängigkeit von der Größe des Spielfelds – eine entsprechende Anzahl von Rechtecken zu zeichnen. In der Originalversion sind dies 200 Rechtecke, 20 Zeilen mit je 10 Rechtecken pro Zeile. Nach dem Start der Anwendung, also bevor das eigentliche Spiel gestartet wird, sind diese Rechtecke alle in der Standardfarbe Grau zu zeichnen. Nach dem Spielstart sind diejenigen Rechtecke, die zu einer Spielfigur (Tetrimino) gehören, in der Farbe zu zeichnen, die diesem Tetrimino zugeordnet ist.

Implementieren Sie eine von der Standardklasse ContentPage abgeleitete Klasse TetrisViewPage, die in etwa das Aussehen von Abbildung 4 besitzt:

Eine Ansicht (View) der Tetris-Anwendung: Klasse TetrisViewPage.

Abbildung 4. Eine Ansicht (View) der Tetris-Anwendung: Klasse TetrisViewPage.

1.8. Interaktionen mit dem Benutzer

Eingaben des Benutzer sind im Original-Tetris durch die vier Pfeiltasten auf der Spielekonsole definiert:

  • Pfeiltaste links: Verschiebt das aktuell in Bewegung befindliche Tetrimino um eine Zelle nach links.

  • Pfeiltaste rechts: Verschiebt das aktuell in Bewegung befindliche Tetrimino um eine Zelle nach rechts.

  • Pfeiltaste oben: Dreht das aktuell in Bewegung befindliche Tetrimino um 90°-Grad im Gegenuhrzeigersinn.

  • Pfeiltaste unten: Verschiebt das aktuell in Bewegung befindliche Tetrimino soweit möglich nach unten.

Da wir es mit einer Smartphone-Anwendung zu tun haben, gibt es mehrere Möglichkeiten, die Interaktionen mit dem Benutzer einzubringen. Zum Einen kann man – beispielsweise am unteren Ende des Bildschirms – vier Button-Elemente platzieren, die entsprechende Bewegungen des aktiven Tetriminos vornehmen. Dies wäre eine vergleichweise einfache Realisierung der Interaktionen mit dem Benutzer

Alternativ könnte man auch auf Berührungsgesten im Spielfeld reagieren. Berührungsgesten am linken oder rechten Spielfeldrand würde man eine Bewegung nach links bzw. rechts zuordnen. Eine Berührung des in Bewegung befindlichen Tetriminos selbst könnte eine Rotation veranlassen. Ein Wischen des Fingers nach unten wäre für das schnelle nach unten Bewegen des aktiven Tetriminos sinnvoll. Diese Realisierung ist möglicherweise mit etwas mehr Aufwand verbunden, wäre aber um etliches intuitiver und würde Ihre Realisierung weitaus attraktiver gestalten.

2. Lösung

Im Gegensatz zu den anderen Programming Assignments dieses Blogs stellen wir dieses Mal die Realisierung nur in Ausschnitten vor. Der Umfang dieses Projekts ist im Laufe der Zeit einfach zu groß geraten, als dass ich mit einem vertretbaren Aufwand auf alle Einzelheiten der Implementierung eingehen könnte. Bei dieser Gelegenheit muss ich leider darauf hinweisen, dass trotz des Projektumfangs nicht alle Funktionalitäten einer Tetris-Anwendung implementiert sind. Am Ende dieses Abschnitts finden Sie deshalb eine ToDo-Liste vor, die zur Weiterarbeit einlädt. Um Sie aber nicht vorweg zu enttäuschen: Der reine Spielablauf ist komplett realisiert. Randthemen wie eine High-Score-Liste oder aber die Berechnung des aktuellen Spiele-Levels sind noch verbesserungsfähig.

Den kompletten Quellcodestapel findet der interessierte Leser unter github.com/peterloos/Xamarin_Tetris.git vor. Eine abgespeckte Version zur Absicherung des Entwicklungskonzepts ist projektbegleitend nativ unter Android entstanden.

Xamarin Forms Anwendungen werden typischerweise auf der Basis des MVVM-Entwurfsmusters realisiert. Am Beispiel der „About“-Aktivität der Tetris-Anwendung kann man deshalb folgende Dateien beobachten:

  • Datei AboutViewPage.xaml – Beschreibt deklarativ in XML die Oberfläche der „About“-Aktivität (Klasse AboutViewPage). Es handelt sich um eine View im Sinne des MVVM-Entwurfsmusters.

  • Datei AboutViewPage.xaml.cs – C#-Codebehind-Datei in Ergänzung zur deklarativen XML-Datei. In einer echten MVVM-Anwendung ist diese Datei (so gut wie) leer, da Aktionen in der Aktivität mit den Hilfsmitteln Databinding und Commands auf das unterlagerte ViewModel weitergereicht werden. Diese Hilfsmittel können ebenfalls deklarativ eingesetzt werden.

  • Datei AboutViewPageModel.csViewModel-Datei (bzw. Klasse) zur View-Klasse AboutViewPage.

Den Quellcode der MVVM-Komponenten zur „About“-Aktivität finden Sie in Listing 10, Listing 11 und Listing 12 vor, eine Model-Klasse gibt es bei dieser einfachen Aktivität nicht:

01: <?xml version="1.0" encoding="utf-8" ?>
02: 
03: <ContentPage
04:     x:Class="AnotherTetrisCross.ViewPages.AboutViewPage"
05:     xmlns="http://xamarin.com/schemas/2014/forms"
06:     xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
07:     xmlns:ext="clr-namespace:AnotherTetrisCross;assembly=AnotherTetrisCross"
08:     BindingContext="{x:Static ext:Locator.AboutViewPageBindingContext}">
09: 
10:     <StackLayout Orientation="Vertical" Padding="5" VerticalOptions="Center">
11:         <Label FontSize="36" HorizontalOptions="CenterAndExpand"
12:                Text="{Binding AboutDisplayText}"
13:                VerticalOptions="Center">
14:             <Label.GestureRecognizers>
15:                 <TapGestureRecognizer Command="{Binding HyperlinkCommand}" />
16:             </Label.GestureRecognizers>
17:         </Label>
18: 
19:         <Button Command="{Binding NavigateCommand}"
20:                 HorizontalOptions="Center" Text="Go back" VerticalOptions="Center" />
21: 
22:     </StackLayout>
23: </ContentPage>

Beispiel 10. Klasse AboutViewPage: XML.


01: namespace AnotherTetrisCross.ViewPages
02: {
03:     using System;
04:     using System.Diagnostics;
05:     using Xamarin.Forms;
06: 
07:     public partial class AboutViewPage : ContentPage
08:     {
09:         public AboutViewPage()
10:         {
11:             InitializeComponent();
12:         }
13:     }
14: }

Beispiel 11. Klasse AboutViewPage: Codebehind.


01: namespace AnotherTetrisCross.ViewModels
02: {
03:     using System;
04:     using System.Diagnostics;
05:     using System.Windows.Input;
06:     
07:     using GalaSoft.MvvmLight;
08:     using GalaSoft.MvvmLight.Views;
09:     using Xamarin.Forms;
10: 
11:     public class AboutViewPageModel : ViewModelBase
12:     {
13:         private readonly INavigationService navigationService;
14: 
15:         public ICommand NavigateCommand { get; set; }
16: 
17:         private String displayText;
18: 
19:         public AboutViewPageModel(INavigationService navigationService)
20:         {
21:             if (navigationService == null)
22:                 throw new ArgumentNullException("NavigationService not provided");
23: 
24:             this.navigationService = navigationService;
25: 
26:             this.displayText = "www.peterloos.de";
27: 
28:             // create commands
29:             this.NavigateCommand =
30:                 new Command(() => { this.navigationService.GoBack(); });
31:         }
32: 
33:         public String AboutDisplayText
34:         {
35:             get
36:             {
37:                 return this.displayText;
38:             }
39:         }
40: 
41:         public ICommand HyperlinkCommand
42:         {
43:             get
44:             {
45:                 return new Command(() =>
46:                 {
47:                     Uri peterloosUri =
48:                         new Uri ("http://www.peterloos.de", UriKind.Absolute);
49:                     Device.OpenUri(peterloosUri);
50:                 });
51:             }
52:         }
53:     }
54: }

Beispiel 12. Klasse AboutViewPageModel: XML.


In den letzten drei Listings können Sie, wenn Sie so wollen, einen Beitrag zur reinen Lehre des MVVM-Entwurfsmusters betrachten. In der Praxis geht dies nicht immer so einfach vonstatten. Am Beispiel der View-Klasse TetrisViewPage können Sie studieren, welche Abweichungen denkbar sind – meines Erachtens aber aus vertretbaren Gründen. Der Vollständigkeit führen wir in Listing 13 und Listing 14 beide Dateien (XML und Codebehind) auf:

001: <?xml version="1.0" encoding="utf-8" ?>
002: 
003: <ContentPage
004:     xmlns="http://xamarin.com/schemas/2014/forms"
005:     xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
006:     x:Class="AnotherTetrisCross.ViewPages.TetrisViewPage"
007:     xmlns:ext="clr-namespace:AnotherTetrisCross;assembly=AnotherTetrisCross"
008:     xmlns:conv="clr-namespace:AnotherTetrisCross.Converters;assembly=AnotherTetrisCross"
009:     BindingContext="{x:Static ext:Locator.TetrisViewPageBindingContext}">
010: 
011:     <ContentPage.Resources>
012:         <ResourceDictionary>
013:             <conv:BoolToColorConverter x:Key="boolConverter" />
014:         </ResourceDictionary>
015:     </ContentPage.Resources>
016:   
017:     <ContentPage.ToolbarItems>
018:         <ToolbarItem
019:             x:Name="ToolbarItems_Settings"
020:             Text="Settings"
021:             Order="Secondary"
022:             Priority="0"
023:             Command="{Binding NavigateSettingsCommand}" />
024: 
025:         <ToolbarItem
026:             x:Name="ToolbarItems_Highscores"
027:             Text="Highscores"
028:             Order="Secondary"
029:             Priority="1"
030:             Command="{Binding NavigateHighScoresCommand}" />
031: 
032:         <ToolbarItem
033:             x:Name="ToolbarItems_About"
034:             Text="About"
035:             Order="Secondary"
036:             Priority="2"
037:             Command="{Binding NavigateAboutCommand}" />     
038:     </ContentPage.ToolbarItems>
039: 
040:     <ContentPage.Content>
041:         <StackLayout
042:             Padding="5"
043:             Orientation="Vertical">
044: 
045:             <Label
046:                 HorizontalOptions="FillAndExpand"
047:                 HorizontalTextAlignment="Center"
048:                 TextColor="Color.White"
049:                 FontSize="30"
050:                 Text="Another Tetris V. 1.00 (Xamarin Forms)" />
051: 
052:             <StackLayout
053:                 Orientation="Horizontal"
054:                 VerticalOptions="Start"
055:                 HorizontalOptions="FillAndExpand">
056: 
057:                 <Button
058:                     Text="Start"
059:                     VerticalOptions="Fill"
060:                     HorizontalOptions="FillAndExpand"
061:                     Command="{Binding StartCommand}" />
062: 
063:                 <Button
064:                     Text="Stop"
065:                     VerticalOptions="Fill"
066:                     HorizontalOptions="FillAndExpand"
067:                     Command="{Binding PauseCommand}" />
068:             </StackLayout>
069: 
070:             <Grid
071:                 VerticalOptions="Fill"
072:                 HorizontalOptions="FillAndExpand">
073:                 
074:                 <Grid.RowDefinitions>
075:                     <RowDefinition Height="Auto" />
076:                 </Grid.RowDefinitions>
077: 
078:                 <Grid.ColumnDefinitions>
079:                     <ColumnDefinition Width="*" />
080:                     <ColumnDefinition Width="*" />
081:                     <ColumnDefinition Width="*" />
082:                 </Grid.ColumnDefinitions>
083: 
084:                 <StackLayout
085:                     Grid.Row="0" Grid.Column="0"
086:                     Padding="5"
087:                     Orientation="Horizontal">
088: 
089:                     <Label
090:                         HorizontalOptions="Fill"
091:                         FontSize="18"
092:                         HorizontalTextAlignment="Start" VerticalOptions="Center"
093:                         Text="Score:"></Label>
094: 
095:                     <Label
096:                         HorizontalOptions="EndAndExpand"
097:                         FontSize="18"
098:                         HorizontalTextAlignment="Start" VerticalOptions="Center"
099:                         Text="{Binding Score}"></Label>
100:                 </StackLayout>
101:                 
102:                 <StackLayout
103:                     Grid.Row="0" Grid.Column="1"
104:                     Padding="5"
105:                     Orientation="Horizontal">
106: 
107:                     <Label
108:                         FontSize="18"
109:                         HorizontalTextAlignment="Start" VerticalOptions="Center"
110:                         Text="Lines:"></Label>
111: 
112:                     <Label
113:                         HorizontalOptions="EndAndExpand"
114:                         FontSize="18"
115:                         HorizontalTextAlignment="Start" VerticalOptions="Center"
116:                         Text="{Binding Lines}" ></Label>
117:                 </StackLayout>
118:                 
119:                 <StackLayout
120:                     Grid.Row="0" Grid.Column="2"
121:                     Padding="5"
122:                     Orientation="Horizontal">
123: 
124:                     <Label
125:                         FontSize="18"
126:                         HorizontalTextAlignment="Start" VerticalOptions="Center"
127:                         Text="Level:"></Label>
128:                     
129:                     <Label
130:                         HorizontalOptions="EndAndExpand"
131:                         FontSize="18"
132:                         HorizontalTextAlignment="Start" VerticalOptions="Center"
133:                         Text="{Binding Level}"></Label>
134:                 </StackLayout>
135:             </Grid>
136: 
137:             <Grid
138:                 VerticalOptions="FillAndExpand"
139:                 HorizontalOptions="FillAndExpand"
140:                 BackgroundColor="Silver"
141:                 Padding="5"
142:                 RowSpacing="5"
143:                 ColumnSpacing="5"
144:                 x:Name="TetrisGrid"/>
145: 
146:             <StackLayout
147:                 Orientation="Horizontal"
148:                 VerticalOptions="End"
149:                 HorizontalOptions="FillAndExpand">
150: 
151:                 <Button
152:                     Text="Left"
153:                     VerticalOptions="Fill"
154:                     HorizontalOptions="FillAndExpand"
155:                     Command="{Binding DoActionCommand}" CommandParameter="Left" />
156: 
157:                 <Button
158:                     Text="Right"
159:                     VerticalOptions="Fill"
160:                     HorizontalOptions="FillAndExpand"
161:                     Command="{Binding DoActionCommand}" CommandParameter="Right" />
162:           
163:                 <Button
164:                     Text="Down"
165:                     VerticalOptions="Fill"
166:                     HorizontalOptions="FillAndExpand"
167:                     Command="{Binding DoActionCommand}" CommandParameter="Down" />
168:           
169:                 <Button
170:                     Text="Rotate"
171:                     VerticalOptions="Fill"
172:                     HorizontalOptions="FillAndExpand"
173:                     Command="{Binding DoActionCommand}" CommandParameter="Rotate" />
174: 
175:             </StackLayout>
176:         </StackLayout>
177:     </ContentPage.Content>
178: </ContentPage>

Beispiel 13. Klasse TetrisViewPage: XML.


01: namespace AnotherTetrisCross.ViewPages
02: {
03:     using System;
04:     using AnotherTetrisCross.ViewModels;
05:     using System.Diagnostics;
06:     using System.Threading.Tasks;
07:     using Xamarin.Forms;
08: 
09:     public partial class TetrisViewPage : ContentPage
10:     {
11:         public TetrisViewPage()
12:         {
13:             InitializeComponent();
14: 
15:             var viewModel = Locator.TetrisViewPageBindingContext;
16: 
17:             // don't want to break the MVVM pattern - poking delegate of
18:             // dialog method into view model (in this way the view model
19:             // is able to practice "inversion of control" for user interactions)
20:             Func<int, Task> lambdaDialog1 = async (score) =>
21:             {
22:                 viewModel.EnterNewScore = await this.GameOverDialog(score);
23:             };
24:             viewModel.GameOverDialog = lambdaDialog1;
25: 
26:             Func<Task> lambdaDialog2 = async () =>
27:             {
28:                 viewModel.ContinueCurrentGame = await this.GamePausedDialog();
29:             };
30:             viewModel.GamePausedDialog = lambdaDialog2;
31: 
32:             // bind box views with a corresponding 'board cell' of the view model
33:             for (int i = 0; i < viewModel.NumRows; i++)
34:             {
35:                 for (int j = 0; j < viewModel.NumColumns; j++)
36:                 {
37:                     BoxView boxView = new BoxView();
38:                     boxView.BindingContext =
39:                         (BoardCellModel)viewModel.BoardCells[j + i * viewModel.NumColumns];
40: 
41:                     boxView.SetBinding(
42:                         BoxView.BackgroundColorProperty,
43:                         new Binding("CellColor"));
44:                     this.TetrisGrid.Children.Add(boxView, j, i);
45: 
46:                     // adding tap recognition
47:                     TapGestureRecognizer recognizer = new TapGestureRecognizer();
48:                     recognizer.SetBinding(
49:                         TapGestureRecognizer.CommandProperty,
50:                         "TapCommand");
51:                     String parameters = String.Format ("Row={0}, Col={1}", i, j);
52:                     recognizer.CommandParameter = parameters;
53:                     boxView.GestureRecognizers.Add(recognizer);
54:                 }
55:             }
56:         }
57: 
58:         // raises alert box to establish a dialog with the user
59:         private async Task<bool> GameOverDialog (int score)
60:         {
61:             String header = String.Format("Game over: {0} Points !", score);
62:             return await DisplayAlert(
63:                 header, "Would you like to enter your Score?", "Yes", "No");
64:         }
65: 
66:         private async Task<bool> GamePausedDialog()
67:         {
68:             return await DisplayAlert(
69:                 "", "The Game has been paused.", "Resume", "Exit");
70:         }
71:     }
72: }

Beispiel 14. Klasse TetrisViewPage: Codebehind.


Ein häufig anzutreffendes Problem beim Einsatz des MVVM-Entwurfsmusters ist das Eintreten von Ereignissen, die in irgendeiner Form zu einer Benachrichtigung des Bedieners an der Benutzeroberfläche führen sollen. Das Aufblenden einer so genannten MessageBox aus dem ViewModel heraus ist prinzipiell so nicht vorgesehen: Ein ViewModel kennt keine Views! Ohne Codebehind-Anweisungen wäre dies nur sehr umständlich (und auch unübersichtlich) zu realisieren. Mit Eigenschaften im ViewModel wie etwa beispielsweise

public Func<int, Task> GameOverDialog { set; get; }

lassen sich Lambda-Ausdrücke dem ViewModel zuweisen, die wiederum im Codebehind definiert sind und die Dialoge (Klasse DisplayAlert) aufblenden. Auf diese Weise lassen sich die Regeln des MVVM-Entwurfsmusters noch halbwegs einhalten.

Wir stehen bei der Konzeption der Tetris-Spielfläche noch vor einem zweiten Problem. Es gibt in Xamarin kein „tabellen“-artiges Steuerelement (zweidimensionales Grid), dem man mittels Datenbindung ein ObservableCollection-Objekt zuordnen kann. Oder einfacher ausgedrückt: Auf einer rein deklarativen Ebene (XML-Beschreibungssprache) lässt sich einem Xamarin-Grid-Steuerelement mittels Datenbinding keine ObservableCollection-Eigenschaft eines ViewModels zuordnen. Oder noch einfacher formuliert: Ich habe es nicht wie von mir gewünscht hinbekommen. In den Zeilen 32 bis 56 finden Sie dies nun auf imperative Weise im Codebehind der TetrisViewPage-Klasse umgesetzt vor. Über die Vor- und Nachteile dieses Lösungsansatzes kann man sicherlich diskutieren, aber: Es war für mich konzeptioneller Schwerpunkt, die Oberfläche des Tetris-Spiels mit den Mechanismen der Datenbindung zu realisieren. In solchen Fällen sind pragmatische Lösungsansätze besser und effizienter als die strikte Einhaltung von Entwurfsrichtlinien. Die Vorteile des MVVM-Entwurfsmusters sind auf diese Weise nicht verloren gegangen, ganz im Gegenteil: Die Eigenschaft BoardCells des ViewModels

public IList<BoardCellModel> BoardCells;

lässt sich in vorzüglicher Weise auch von einer automatisierten SW-Schicht aus ansprechen.

In der vorliegenden Version wurde das Tetris-Projekt um einige Bibliotheken erweitert (NuGet Packages Manager), ohne die das Programm so nicht realisierbar gewesen wäre. So sollte man zur Unterstützung des MVVM-Entwurfsmusters das MVVM Light Toolkit-Paket von Laurent Bugnion einsetzen (siehe auch hier). Zum Einen wird mit Hilfe einer Basisklasse ViewModelBase (Namensraum GalaSoft.MvvmLight) für ViewModel-Klassen das Arbeiten mit Kommandos vereinfacht. Zum Anderen wird auch die Navigation zwischen den unterschiedlichen Aktivitäten der App auf Basis einer Implementierung der Serviceschnittstelle INavigationService (Namensraum GalaSoft.MvvmLight.Views) leichter.

Das Einrichten und Verwalten einer „Settings“-Aktivität kann mit recht viel Arbeitsaufwand verbunden sein, wenn man für jede der Plattformen (iOS, Android, Windows UWP) eine separate Realisierung beisteuert. Mit dem „Settings Plugin for Xamarin And Windows“ schlägt man mehrere Fliegen mit einer Klappe. Unter nuget.org/packages/Xam.Plugins.Settings/ findet man alles Notwendige zur Installation vor. Den Quellcode des Plugins von Autor James Montemagno kann man auf Github ebenfalls einsehen.

Den Einsatz eines Dependency Injection Containers kann man mit dem PlugIn Microsoft.Practices.ServiceLocation vereinfachen. Im wesentlichen geht es um eine Klasse ServiceLocator (Namensraum Microsoft.Practices.ServiceLocation), die in der Locator-Klasse der Tetris-App eingesetzt wird. Hier kommt – wiederum aus dem MVVM Light Toolkit-Paket von Laurent Bugnion – ein einfacher DI Container namens SimpleIoc (Namensraum GalaSoft.MvvmLight.Ioc) zum Einsatz.

An welchen Stellen bietet sich eine Weiterarbeit an? Die Highscore-Liste geht zum jetzigen Zeitpunkt nach dem Schließen der App verloren. In einem ersten Schritt sollte die Liste lokal auf einem Device funktionieren. Entsprechende Vorkehrungen in Form der Integration des „Settings Plugins for Xamarin And Windows“ sind bereits getroffen worden. In einem weiteren Schritt könnte man die App an das „Google Play Games“-Portal anschließen. Mit diesem Gaming-Hub für Android ließen sich dann die Highscores aller Spieler auf unterschiedlichen Devices zentral verwalten.

Die Implementierung von erzielten Punkten und Spiele-Levels ist ebenfalls nur in Ansätzen vorhanden. Die abschließenden Screenshots mögen ein Eindruck vom augenblicklichen Entwicklungsstand vermitteln und zur kreativen Weiterarbeit einladen:

Das Tetris-Spiel in Aktion

Abbildung 5. Das Tetris-Spiel in Aktion


Die High-Score Liste des Tetris-Spiels

Abbildung 6. Die High-Score Liste des Tetris-Spiels


Und nicht zu vergessen: Das Spiel kann auch auf einem Windows PC als UWP App ausgeführt werden:

Die Xamarin Tetris App als Windows UWP App

Abbildung 7. Die Xamarin Tetris App als Windows UWP App