Muliplayer Tic-Tac-Toe mit Firebase Realtime Datebase und Cloud Functions

1. Aufgabe

In dieser Fallstudie wenden wir uns dem Spieleklassiker „Tic-Tac-Toe“ zu. In Zeiten mobiler Devices realisieren wir das Spiel als Multiplayer App unter Android mit einer Backend-as-a-Service (BaaS) Anbindung an Googles Back-End-Datenspeicher Firebase.

Auch wenn vielen von Ihnen Tic-Tac-Toe bekannt sein dürfte, erläutern wir zunächst kurz das Spielprinzip. Zwei Spieler treten gegeneinander an – jeder mit einer aktiven Instanz unserer App auf seinem Android Device. Die beiden Spieler betreten zu diesem Zweck einen fiktiven Spielraum („Lobby“) der App und weisen sich dabei einen Spitznamen zu (Nickname). Erst wenn beide Spieler den Spielraum betreten haben, schaltet das Firebase-Backend auf beiden Devices das Spiel frei.

Das Spielbrett ist in eine Matrix von drei mal drei Feldern aufgeteilt. Pro Spielzug besetzt ein Spieler ein Feld auf dem Spielbrett, das zu Anfangs neun leere Felder aufweist. Der Stein des ersten Spielers sieht wie ein großes „X“ aus, der des zweiten wie ein großes „O“. Die Spieler sind abwechselnd an der Reihe. Die App (bzw. das Firebase Backend) erkennt, welcher Spieler zuerst den Spielraum betreten hat und lässt diesen Spieler das Spiel beginnen. Sobald es einem Spieler gelungen ist, drei Felder waagerecht, senkrecht oder diagonal zu besetzen, hat dieser das Spiel gewonnen. Es ist nach 9 Zügen auch möglich, dass keiner der beiden Spieler gewonnen hat. Das Spiel endet dann unentschieden. Endet das Spiel nicht Unentschieden, erhört sich der Punktestand des Siegers um einen Punkt. Wollen die beiden Spieler noch einmal gegeneinander antreten, darf der Spieler, der zuletzt verloren hat, mit dem neuen Spiel beginnen.

Für die Gestaltung der App Oberfläche finden Sie Anregungen in Abbildung 1 vor. Zwei Spieler mit den Nicknames „Hans“ und „Sepp“ spielen gerade gegeneinander. Das grün hinterlegte Textfeld von Spieler „Sepp“ bringt zum Ausdruck, dass dieser an der Reihe ist.

Oberfläche des Multiplayer Tic-Tac-Toe Spiels.

Abbildung 1. Oberfläche des Multiplayer Tic-Tac-Toe Spiels.


In der Einleitungsphase des Spiels betreten die beiden Spieler zunächst den Spielraum. Wir erkennen dies an dem Umstand, dass die Spitznamen der beiden Spieler blau hinterlegt sind (Abbildung 2):

Einleitungsphase des Spiels.

Abbildung 2. Einleitungsphase des Spiels.


Während des Spiels unterscheiden sich die Oberflächen der App zum selben Zeitpunkt. In Abbildung 3 und Abbildung 4 finden Sie zwei Screenshots des Spiels vor – aufgenommen zum selben Zeitpunkt:

Aktive Spielphase – Spieler Hans ist gerade nicht am Zug.

Abbildung 3. Aktive Spielphase – Spieler Hans ist gerade nicht am Zug.


Aktive Spielphase – Spieler Sepp ist gerade am Zug.

Abbildung 4. Aktive Spielphase – Spieler Sepp ist gerade am Zug.


Nach dem Spiel wird auf beiden Devices eine Android-Toast Meldung aufgeblendet. In Abhängigkeit vom Spielverlauf lautet diese entweder „Sorry you've lost the game!“ oder „Yeaah you're the winner!“ (Abbildung 5). Die Hintergrundfarben der beiden Spieler wechseln wieder in den Blau-Modus. Mit einem Klick auf die „Another Game“-Schaltfläche wird das Spielfeld gelöscht; ein Klick auf „Start“ startet das Spiel von Neuem. Der Verlierer des letzten Spiels darf nun starten.

Oberfläche des Spiels nach einer Niederlage.

Abbildung 5. Oberfläche des Spiels nach einer Niederlage.


Entwickeln Sie eine Muliplayer-Realisierung des Tic-Tac-Toe-Spiels auf der Basis von Firebase Realtime Datebase und Cloud Functions.

Die generelle Architektur der Lösung meiner „Multiplayer Tic-Tac-Toe“-App ist an das Model-View-Controller-Paradigma (MVC) angelehnt. Prinzipiell müssen wir bei diesem Entwurfsmuster die drei Teile

  • Model – Daten der Anwendung,

  • View – ein oder mehrere Ansichten, die das Modell visualisieren und

  • Control – Zwischenschicht, die Benutzereingaben verwaltet und diese zwischen der View und dem Modell geeignet übermittelt

unterscheiden. Alle drei Kompenenten des Entwurfsmuster müssen dabei weitestgehend unabhängig von den anderen Teilen realisiert werden. Historisch gesehen hat sich das MVC-Entwurfsmuster beim Entwurf von Benutzeroberflächen für Desktop-Applikationen entwickelt. Übertragen auf eine App mit lokalen Zustandsdaten und einem Firebase Cloud-Backend bietet sich eine Aufteilung der einzelnen Software-Schichten wie folgt an:

  • Das Modell – die Daten der App – legen wir in der Realtime-Database von Firebase ab. Dazu zählen im wesentlichen die Informationen der zwei agierenden Spieler und der Zustand des Spielebretts. Die Kontrolle über den Verlauf des Spiels – gemeinhin auch als Business-Logik bezeichnet –, setzen wir mit der Technologie der Firebase-Functions um. Im Großen und Ganzen haben wir es hierbei mit dem Regelwerk von Tic-Tac-Toe zu tun.

  • Die Ansicht (Oberfläche) der Anwendung residiert auf den beteiligten mobilen Devices. Etwas technologischer formuliert haben wir es mit Activities in einer Android-App zu tun. Zu beachten: Auf den mobilen Devices ist keinerlei Spiele-Logik beheimatet. Eine kleine Besonderheit in der Betrachtung der Ansichten ist der Umstand, dass diese unterschiedlichen Spielern zugeordnet sind und damit ein unterschiedliches Verhalten aufweisen müssen. Ist zu einem bestimmten Zeitpunkt Spieler A mit seinem Zug an der Reihe (Ansicht gestattet Touch-Eingaben, Hinweistext „Bitte ziehen Sie“ an der Oberfläche vorhanden, etc.), sind zum gleichen Zeitpunkt auf dem Device von Spieler B Touch-Eingaben zu ignorieren (Hinweistext „Spieler A ist an der Reihe“, etc.).

  • Die Controller-Funktionalität ist in den Activities der Apps mit in die Ansicht zu integrieren. Man spricht hier beim MVC-Entwurfsmuster auch von einem so genannten „UI-Delegierten“. Im wesentlichen handelt es sich bei der Eingabe nur um Touch-Gestiken, um einen Spielestein zu setzen. Ein eher größerer Aufwand des Controllers besteht darin, falsche Eingaben (Spielfeld bereits belegt, Spieler löst Touch-Gestiken aus, ist aber nicht an der Reihe, ...) zu erkennen und abzuweisen. Auf Grund der Verteilung der Software auf Cloud-Funktionen und mobile Devives sollte besondere Achtsamkeit darauf gelegt werden, nur wesentliche Daten zur Verarbeitung der Spielelogik an den Cloud-Server zu senden. Etwaige Überprüfungen zulässiger Spielezüge und -eingaben sollten, soweit möglich, in den Apps lokal vor Ort ohne (überflüssigen) Datenaustausch mit dem Firebase Cloud-Backend stattfinden.

Die Umsetzung des Model-View-Controller-Paradigmas in unserem Anwendungsfall zeichnet sich offensichtlich durch einen sehr „verteilten Charakter“ aus. Die Implementierung der Spielelogik (Modell) erfolgt an einer zentralen Stelle (Firebase Cloud-Backend mit Firebase Functions). Die Darstellung der aktuellen Spielesitutation (View) und die intelligente Verarbeitung von Benutzereingaben (Controller) erfolgt dezentral auf den mobilen Devices. Kommen wir damit auf das Modell der Anwendung näher zu sprechen. Ihm obliegt im Großen und Ganzen die Verwaltung der drei Datenbereiche

  • Spieler,

  • Spielbrett und

  • Spielzustand.

Ein Spieler besitzt einen Namen und einen Zeitstempel, der seinen Anmeldezeitpunkt wiedergibt. Weitere Daten sind sein Score und der ihm zugeordnete Spielstein („X“ oder „O“). In Abbildung 6 finden Sie den entsprechenden Aufbau des JSON-Objekts players für zwei Spieler Hans und Sepp vor:

Zwei Spieler Hans und Sepp in der Realtime-Database von Firebase.

Abbildung 6. Zwei Spieler „Hans“ und „Sepp“ in der Realtime-Database von Firebase.


Die Datenablage eines Tic-Tac-Toe Spielbretts sieht wie in Abbildung 7 gezeigt aus. Da es in der Firebase-Realtime-Database keine Arrrays, sondern nur Objekte gibt, liegt ein JSON-Objekt mit der in Abbildung 7 gezeigten Struktur nahe. Alle Werte der Variablen state können den Wert „Empty“, „X“ oder „O“ annehmen:

Ein Tic-Tac-Toe Spielbrett in der Realtime-Database von Firebase.

Abbildung 7. Ein Tic-Tac-Toe Spielbrett in der Realtime-Database von Firebase.


Um das Spielbrett programmiersprachlich zu initialisieren, genügt in JavaScript ein Objekt des Aussehens

const EmtpyBoard = {
    row1 : {
        col1: { state : "Empty" },
        col2: { state : "Empty" },
        col3: { state : "Empty" }
    },
    row2 : {
        col1: { state : "Empty" },
        col2: { state : "Empty" },
        col3: { state : "Empty" }
    },
    row3 : {
        col1: { state : "Empty" },
        col2: { state : "Empty" },
        col3: { state : "Empty" }
    }
};

Wir fahren mit der Betrachtung des Modells fort und kommen nach den Daten auf die Umsetzung der Spiele-Logik zu sprechen. In Firebase bedeutet dies, dass wir die Firebase Realtime-Database verlassen und uns den Firebase Cloud Functions zuwenden. Funtionen, die in diesem Bereich angesiedelt sind, werden an Hand bestimmter Ereignisse zur Ausführung gebracht. In unserer Anwendung sind dies Werteveränderungen von Daten innerhalb der Realtime-Database. Dazu muss man sich mit der Firebase-Funktion

functions.database.ref('/board')

an Daten unterhalb eines bestimmten Pfads anmelden. In diesem Fall (Funktion ref) würde man sich für Ereignisse aller Art unterhalb des Pfads /board anmelden, die in Folge einer write, create, update oder delete-Operation ausgelöst würden. Man könnte dies aber auch selektiver gestalten, indem man zusätzlich zur ref-Funktion eine der vier Funktionen

  • onWrite()

  • onCreate()

  • onUpdate()

  • onDelete()

verwendet. Sind wir beispielsweise an allen Werteänderungen an einem Tic-Tac-Toe Spielbrett interessiert, dann würde der komplette Registrierungsaufruf so aussehen:

exports.triggerBoard = functions.database.ref('/board').onUpdate((event) => { ... });

Das Argument des onUpdate-Methodenaufrufs, eine anonyme Funktion – genauer: eine so genannte Pfeilfunktion oder arrow function aus dem Sprachstandard ECMAScript 6 – reagiert bei allen Änderungen an Variablen des JSON-Objekts unterhalb von des Pfads /board. Der Parameter event der onUpdate-Funktion besitzt unter anderem den Wert des JSON-Objekts vor und nach dem Schreiben der Werteänderung. Auf diese Weise kann man in unserem Fall einfach ermitteln, an welcher Stelle auf dem Spielbrett ein Spielstein gesetzt wurde. Wurde einem Element der Wert „Empty“ zugewiesen, muss es sich um eine Neu-Initialisierung des Bretts handeln; wir tun an dieser Stelle nichts weiter. Wurde einer der beiden Werte „X“ oder „O“ geschrieben, handelt es sich um die Aktion eines Spielers. Da die Spiele-Frontends falsche Eingaben abweisen, müssen wir uns im Backend glücklicherweise nicht um die Behandlung fehlerhafter Situationen kümmern.

Die Menge aller in den Firebase Cloud Functions registrierten Trigger-Funktionen werden übersichtsartig in einem Firebase Dashboard dargestellt (Abbildung 8):

Dashboard der Firebase Cloud Functions.

Abbildung 8. Dashboard der Firebase Cloud Functions.


In Abbildung 8 können wir bei genauem Hinsehen in der linken Hälfte (Spalte „Funktion“) zwei Triggerfunktionen triggerBoard und triggerCommand erkennen. In der Spalte „Ereignis“ findet man die Variablen aus der Firebase Realtime-Database vor, auf Grund derer Werteveränderungen zum Aufruf der Trigger-Funktion führen. Die Variable /board hatten wir in Abbildung 7 bereits vorgestellt. Die zweite Trigger-Funktion triggerCommand ist dem Knoten /control/command zugeordnet. Hier sind einige Variablen in einem JSON-Objekt /control vereint, um entweder Kommandos von den Apps an das Cloud-Backend übermitteln zu können (Pfad /control/command) oder aber den aktuellen Spielestatus für die Apps sichtbar zu schalten (Pfad /control/status). Die Trigger-Funktion triggerCommand ihrerseits ist im Pfad /control/command tieferliegend registriert, also nicht am Pfad /control. Werteänderungen an den Variablen unterhalb von /control/status sind für die Apps bedeutsam; eine Werteänderung spielt serverseitig keine Rolle. Stark vereinfacht könnte man auch sagen: Der Pfad /control/command ist für die Übertragung von Daten in Richtung App zu Cloud-Backend eingerichtet, der Pfad /control/status für die umgekehrte Richtung (Abbildung 9 ):

Cloud-Datenbereich für eine Kommandoschnittstelle zwischen App und Cloud Backend.

Abbildung 9. Cloud-Datenbereich für eine Kommandoschnittstelle zwischen App und Cloud Backend.


Nun können wir langsam auf die Details des Tic-Tac-Toe Spielebackends eingehen. An einigen Stellen der vorangegangenen Erläuterungen ist es bereits zum Vorschein gedrungen: Zum Zeitpunkt der Verfassung dieser Fallstudie steht als Programmiersprache für Firebase Cloud Functions nur JavaScript zur Verfügung, ergänzt bzw. angelehnt an den Sprachstandard ECMAScript 6. Moderne Sprachmittel wie Lambda-Funktionen (im ECMAScript als Pfeilfunktionen bezeichnet) oder Promises stehen zur Verfügung und stellen ein wichtiges Fundament für die Backend-Programmierung dar.

Zurück zum Spielebrett unserer Tic-Tac-Toe Anwendung. Zur Ermittlung eines gespielten Steins müssen wir das Spielbrett vor und nach einem Spielzug vergleichen. Da sich JSON-Objekte nicht so leicht wie Arrays vergleichen lassen, habe ich eine JavaScript-Hilfsfunktion boardToArray zum Umwandeln eines Spielbrett-JSON-Objekts in ein Array geschrieben:

function boardToArray(board) {

    const elem11 = board.row1.col1.state;
    const elem12 = board.row1.col2.state;
    const elem13 = board.row1.col3.state;

    const elem21 = board.row2.col1.state;
    const elem22 = board.row2.col2.state;
    const elem23 = board.row2.col3.state;

    const elem31 = board.row3.col1.state;
    const elem32 = board.row3.col2.state;
    const elem33 = board.row3.col3.state;

    return [
        [elem11, elem12, elem13],
        [elem21, elem22, elem23],
        [elem31, elem32, elem33]
    ];
}

Wurde ein neuer Stein auf das Spielbrett gesetzt, muss in den zwei Feldern, die das Spielbrett vor und nach dem Zug beschreiben, genau an einer Stelle ein unterschiedlicher Wert sein. Diesen aufzuspüren übernimmt die JavaScript-Hilfsfunktion searchLastMove:

function searchLastMove(prevBoard, currBoard) {

    var lastStone = new Object();
    lastStone.row = -1;
    lastStone.col = -1;
    lastStone.stone = GameStoneEmpty;

    for (var row = 0; row < 3; row ++) {

        for (var col = 0; col < 3; col ++) {

            if (prevBoard[row][col] !== currBoard[row][col]) {

                lastStone.row = row;
                lastStone.col = col;
                lastStone.stone = currBoard[row][col];
                return lastStone;
            }
        }
    }

    return lastStone;
}

Einige konstante Werte des Spielbretts haben wir in konstanten Variablendeklarationen zusammengestellt:

// game board stones
const GameStoneX = "X";
const GameStoneO = "O";
const GameStoneEmpty = "Empty";

Das Setzen eines Steines auf das Spielfeld hat zur Folge, dass das Spiel entweder zu Ende ist oder der andere Spieler an der Reihe ist. Eine dritte Besonderheit gilt es ebenfalls nicht zu übersehen: Gibt es keine freien Felder mehr zum Setzen eines Spielsteins, endet das Spiel Unentschieden. In Kenntnis der beiden Objekte „aktuelles Spielbrett“ (Parameter board) und „zuletzt gesetzter Stein“ (Parameter stone) können wir nun eine JavaScript-Funktion checkForEndOfGame schreiben:

function checkForEndOfGame(board, stone) {

    var result = new Object();
    result.isGameOver = false;
    result.isADraw = false;

    // test columns
    for (var row = 0; row < 3; row++) {
        if (board[row][0] === stone && board[row][1] === stone && board[row][2] === stone) {
            result.isGameOver = true;
            return result;
        }
    }

    // test rows
    for (var col = 0; col < 3; col++) {
        if (board[0][col] === stone && board[1][col] === stone && board[2][col] === stone) {
            result.isGameOver = true;
            return result;
        }
    }

    // test diagonals
    if (board[0][0] === stone && board[1][1] === stone && board[2][2] === stone) {
        result.isGameOver = true;
        return result;
    }

    if (board[2][0] === stone && board[1][1] === stone && board[0][2] === stone) {
        result.isGameOver = true;
        return result;
    }

    // could be a draw
    var emptyStones = 0;
    for (row = 0; row < 3; row++) {
        for (col = 0; col < 3; col++) {
            if (board[row][col] === GameStoneEmpty) {
                emptyStones++;
                break;
            }
        }
    }
    if (emptyStones === 0) {

        result.isGameOver = true;
        result.isADraw = true;
        return result;
    }

    return result;
}

Die bisher vorgestellten JavaScript-Funktionen hatten den Charakter reiner „Hilfsfunktionen“, mit ihrer Hilfe können wir uns nun der Realisierung der eigentlichen „Trigger“-Funktionen zuwenden. Änderungen am Spielbrett und folglich an einem Realtime-Database Objekt (hier: Objekt /board) bedeuten unter anderem, dass die zugeordnete Trigger-Funktion zunächst einmal keinerlei Kenntnisse über die anderen Objekte (Daten) des zugeordneten Firebase-Projekts hat. Diese müssen, wenn man sie benötigt, innerhalb der Cloud-Function asynchron gelesen werden. Damit sind wir in JavaScript beim Sprachfeature der Promises angekommen. Ein Promise ist ein Objekt, das asynchrone Operationen in einer leicht gehaltenen Schnittstelle kapselt. Auf das Promise-Objekt kann man bereits zugreifen, noch bevor die eigentliche asynchrone Tätigkeit abgeschlossen ist. Promises stellen damit ein vorläufiges Resultat einer asynchronen Operation dar, um an ihm mögliche Folgetätigkeiten formulieren zu können. Dies betrifft in der Regel den „Gut“-Fall wie auch das Reagieren auf einen „Fehler“-Fall.

Die Syntax von Promises wird so gewählt, dass sich mit Promise-Objekten auch so genannte „Promise-Ketten“ bilden lassen. Diese Verkettung erfolgt dadurch, dass im then-Block eines Promise-Objekts wiederum ein zweites, „inneres“ Promise-Objekt zurückzuliefern ist. An Hand dieser Vorbemerkungen sollte die Trigger-Funktion aus Listing 1 in ihren Grundzügen verständlich sein:

001: exports.triggerBoard = functions.database.ref ('/board').onUpdate(
002: 
003:     (event) => {
004: 
005:         var keyOfNextPlayer;
006:         var keyOfWinner;
007:         var lastScoreOfWinner;
008: 
009:         const previousBoard = event.data.previous.val();
010:         var prevArray = boardToArray(previousBoard);
011: 
012:         const currentBoard = event.data.current.val();
013:         var currArray = boardToArray(currentBoard);
014: 
015:         var lastMovedStone = searchLastMove (prevArray, currArray);
016:         if (lastMovedStone.stone === GameStoneEmpty) {
017: 
018:             console.log('Ignorig Empty stone ...');
019:             return null;
020:         }
021: 
022:         // set stone
023:         var result = checkForEndOfGame(currArray, lastMovedStone.stone);
024: 
025:         if (! result.isGameOver) {
026: 
027:             console.log('Game *not* over');
028:             return admin.database().ref('/players').once('value').then ((snapshot) => {
029: 
030:                 var arrPlayers = snapshotToArray (snapshot);
031: 
032:                 var stoneOfNextPlayer;
033: 
034:                 if (arrPlayers[0].stone === lastMovedStone.stone) {
035: 
036:                     keyOfNextPlayer = arrPlayers[1].key;
037:                     stoneOfNextPlayer = arrPlayers[1].stone;
038:                 }
039:                 else if (arrPlayers[1].stone === lastMovedStone.stone) {
040: 
041:                     keyOfNextPlayer = arrPlayers[0].key;
042:                     stoneOfNextPlayer = arrPlayers[0].stone;
043:                 }
044: 
045:                 var status = {
046:                     id : GameActive,
047:                     parameter1 : keyOfNextPlayer,
048:                     parameter2 : stoneOfNextPlayer
049:                 };
050: 
051:                 return event.data.ref
052:                     .parent
053:                     .child('control')
054:                     .child('status')
055:                     .set(status);
056: 
057:             }).then (() => console.log('Player with id ' + keyOfNextPlayer + ' plays next'));
058:         }
059:         else if (! result.isADraw) {
060: 
061:             console.log('Game *over*');
062: 
063:             return admin.database().ref('/players').once('value').then ((snapshot) => {
064: 
065:                 var arrPlayers = snapshotToArray (snapshot);
066:                 var indexOfWinner = (arrPlayers[0].stone === lastMovedStone.stone) ? 0 : 1;
067: 
068:                 lastScoreOfWinner = arrPlayers[indexOfWinner].score;
069:                 lastScoreOfWinner++;
070: 
071:                 keyOfWinner = arrPlayers[indexOfWinner].key;
072: 
073:                 return event.data.ref
074:                     .parent
075:                     .child('players')
076:                     .child(arrPlayers[indexOfWinner].key)
077:                     .child('score')
078:                     .set(lastScoreOfWinner);
079: 
080:             }).then (() => {
081: 
082:                 var status = {
083:                     id : GameOver,
084:                     parameter1 : keyOfWinner,
085:                     parameter2 : lastScoreOfWinner.toString()
086:                 };
087: 
088:                 return event.data.ref
089:                     .parent
090:                     .child('control')
091:                     .child('status')
092:                     .set(status);
093: 
094:             }).then (() => console.log('Player with id ' + keyOfWinner + ' has won the game!'));
095:         }
096:         else {
097: 
098:             console.log("Game *over* -- it's a draw !!!");
099: 
100:             return event.data.ref
101:                 .parent
102:                 .child('control')
103:                 .child('status')
104:                 .set({id : GameOver, parameter1 : '', parameter2 : '' })
105:                 .then (() => console.log("Game *over* -- it's a draw !!!"));
106:         }
107:     }
108: );

Beispiel 1. Firebase Cloud Function „triggerBoard


In der Trigger-Funktion aus Listing 1 kommen einige globale JavaScript-Variablen zum Einsatz:

/*
 *   common constant data
 */

// firebase utils
const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp (functions.config().firebase);

// game state within cloud functions
const GameIdle = "GameIdle";
const GameActive = "GameActive";
const GameOver = "GameOver";

// game commands
const GameCommandIdle = "idle";
const GameCommandClear = "clear";
const GameCommandStart = "start";

Auf einige Anweisungen in Listing 1 sollten wir noch einmal näher eingehen. In Zeile 28 zum Beispiel wird die Liste der beiden Spieler benötigt, um dem siegreichen Spieler einen Punkt gutschreiben zu können. Ein Aufruf von admin.database().ref('/players').once('value') stellt diese Liste (asynchron) bereit. Ein asynchroner Funktionsaufruf kommt selten alleine, in Zeile 73 geht es mit demselben Strickmuster weiter, um – wiederum im Kontext eines asynchrones Funktionsaufrufs – den Datensatz des siegreichen Spielers aktualisieren zu können. Die Asynchronität spiegelt sich immer im then-Block des entsprechenden Funktionsaufrufs wieder, hier geht es im „Gut“-Fall weiter.

Um den beiden lauschenden Apps das Signal zur Fortsetzung des Spiels zu übermitteln, müssen wir an der Kommando-Schnittstelle zwischen App und Cloud eingreifen. In den Zeilen 51 bzw. 88 wird unterhalb des Pfads /control/status ein JSON-Objekt aktualisiert, an Hand dessen die beiden lauschenden Apps ihre Weiterarbeit koordinieren können.

Um ein Spiel zu starten oder für einen Neustart in den Grundzustand zu versetzen, lauscht die Firebase-Funktion triggerCommand unterhalb des Pfads /control/command auf Änderungen (Listing 2). Für die beiden Kommando-Zeichenketten "start" und "clear" finden wir dort eine Implementierung vor:

01: exports.triggerCommand = functions.database.ref ('/control/command').onUpdate (
02: 
03:     (event) => {
04: 
05:         if (!event.data.exists()) {
06:             return null;
07:         }
08: 
09:         const command = event.data.val();
10: 
11:         console.log('Command => [' + command + ']');
12: 
13:         if (command === GameCommandIdle) {
14: 
15:             console.log('Command has been cleared');
16:             return null;
17:         }
18:         else if (command === GameCommandStart) {
19: 
20:             return admin.database().ref('/players').once('value').then ((snapshot) => {
21: 
22:                 var arrPlayers = snapshotToArray (snapshot);
23:                 if (arrPlayers.length !== 2) {
24: 
25:                     console.log('Room of players not complete: ' + arrPlayers.length);
26:                     return null;
27:                 }
28: 
29:                 // decide, which player begins - either comparing scores or time stamps
30:                 var index = 0;
31:                 if (arrPlayers[0].score === arrPlayers[1].score) {
32: 
33:                     // scores are equal, use timestamp: 'first come, first serve'
34:                     if (arrPlayers[1].timestamp <= arrPlayers[0].timestamp) {
35:                         index = 1;
36:                     }
37:                 }
38:                 else if (arrPlayers[0].score < arrPlayers[1].score) {
39: 
40:                     index = 0;
41: 
42:                 } else {
43: 
44:                     index = 1;
45:                 }
46: 
47:                 // kick-off begin of game
48:                 console.log('Kick-off begin of game');
49:                 var key = arrPlayers[index].key;
50: 
51:                 return event.data.ref
52:                     .parent
53:                     .parent
54:                     .child('control')
55:                     .child('status')
56:                     .set({ id : GameActive, parameter1 : key, parameter2 : ''});
57: 
58:             }).then(() => {
59: 
60:                 return event.data.ref.set (GameCommandIdle);
61: 
62:             }).then(() => console.log('Trigger /control/command/ done!'));
63: 
64:         }
65:         else if (command === GameCommandClear) {
66: 
67:             return event.data.ref
68:                 .parent
69:                 .parent
70:                 .child('board')
71:                 .set(EmtpyBoard).then (() => {
72: 
73:                 return event.data.ref.set (GameCommandIdle);
74: 
75:             }).then(() => console.log('Trigger /control/command/ done!'));
76:         }
77:         else {
78: 
79:             console.log('Internal Error: Unknown command => ' + command);
80:             return null;
81:         }
82:     }
83: );

Beispiel 2. Firebase Cloud Function „triggerCommand


Für den administrativen Aufwand (Erstellung und Deployment von Firebase Funktionen) verweise ich auf die entsprechenden Websites der Firebase Dokumentation. Im wesentlichen benötigt man Node.js und den Paketmanager npm, bevor man loslegen kann. Mit dem Kommando firebase deploy kopiert man sein lokales Firebase Functions Projekt zum Server (Abbildung 10):

Firebase Command Line Interface Tool (CLI).

Abbildung 10. Firebase Command Line Interface Tool (CLI).


Zum Testen von Firebase Functions gibt es leider noch nicht die Möglichkeit des Debuggens. Mit der server-seitigen Log-Funktion console.log kann man Ausgaben in ein webbasiertes Log-Protokoll tätigen, siehe Abbildung 11:

Beispiel einer Firebase Stackdriver Logging Website.

Abbildung 11. Beispiel einer Firebase Stackdriver Logging Website.


Damit verlassen wir die Betrachtungen des Firebase Backends und wenden uns dem Client zu. Auf einige Aspekte in der Realisierung der App bin ich etwas intensiver eingegangen. Dazu zählt beispielsweise das auf den ersten Blick recht trivial anmutende Problem, einen Spielraum zu betreten, der maximal zwei Spieler aufnehmen kann („Lobby“). Konkret geht es also um die Betrachtung des Problems, wenn auf zwei mobilen Devices zwei Spieler zeitgleich den Spielraum betreten wollen.

Serverseitig stellt ein Zugriff von mehreren Clients zeitgleich kein Problem dar, da hier alle Aktivitäten (zwangs-)serialisiert werden. Für einen Client bedeutet das Betreten eines Spielraums, dass diese Aktion zum selben Zeitpunkt auch auf anderen Clients angestoßen werden könnte. Da auf einem Client serverseitige Zugriffe grundsätzlich asynchron ausgeführt werden, wird der Vorgang etwas komplizierter als erwartet. Das „erfolgreiche Betreten“ eines Raumes kann nicht über den Rückgabewert eines entsprechenden Methodenaufrufs direkt (synchron) abgefragt werden, da eben dieser Rückgabewert erst mit einer gewissen zeitlichen Verzögerung eintrifft.

Um den Vorgang „erfolgreiches Betreten eines Raumes“ auf einem Endgerät korrekt umzusetzen, müsste man

  1. einen Funktionsaufruf mit der Anfrage „Raum betreten“ absetzen. Der Aufruf ist (wie alle Firebase-Funktionsaufrufe) asynchron. Sprich nach der unmittelbaren Rückkehr dieses Funktionsaufrufs ist nicht bekannt, ob der Raum betreten werden konnte oder ob quasi zeitgleich ein anderer Spieler dem Ansinnen zuvor gekommen ist.

  2. nach einer gewissen Latenzzeit einen zweiten Funktionsaufruf an den Firebase-Server absetzen, um zu überprüfen, ob der vorhergehende Funktionsaufruf erfolgreich war („betreffender Spieler ist im Raum“) oder nicht.

Dies ist für die Realisierung der eigentlich gewünschten Funktionalität („einen Raum betreten“) viel zu umständlich. Im Prinzip haben wir es hier mit einem klassischen Problem aus dem Umfeld der Systemprogrammierung zu tun, der so genannten atomaren Operation. Hierunter versteht man den Sachverhalt, eine Reihe von Anweisungen ohne Unterbrechung durch andere rechenwillige Interessenten bis zum Ende zu durchlaufen

Übertragen auf unser Beispiel besteht das Betreten eines Raumes aus einer ganzen Reihe von Anweisungen auf dem Server wie z.B. Hinzufügen eines Spielers zu einer Spielerliste eines Raums, Inkrementieren einer Zählervariablen (wie viele Spieler sind im Raum), Auslösen von Ereignissen (die Anzahl der Mitglieder im Raum hat sich geändert), etc. Könnte man (auf dem Server) diese Folge von Anweisungen unterbrechen, so wäre die Integrität der Funktion nicht mehr gewährleistet, da auf Grund der Unterbrechung einzelne Variablen (Liste mit den Spielern, Zählervariable mit der Anzahl der Spieler) falsche Werte annehmen könnten.

Details zum Thema atomare Operation kann man einschlägigen Kapiteln der klassischen Systemprogrammierung gängiger Betriebssysteme wie etwa Nebenläufigkeit oder konkurrierender Zugriff und Synchronisation entnehmen. Nur mit dem Unterschied eben, dass in Bezug auf unsere Anwendung das Inkrementieren der Variablen auf Apps angestoßen wird, die Variable selbst aber in der Firebase Cloud liegt. Ein Lösungsansatz hierzu besteht im Gebrauch von Firebase-Transaktions-Operationen: Um zu verhindern, dass mehr als zwei Spieler den Spielerraum betreten, ist vor dem Eintritt ein Ticket zu ziehen. Das Ticket mit der Nummer 1 wird an den ersten Spieler vergeben, der eine Anfrage stellt. Entsprechend erhält der zweite Spieler das Ticket mit der Nummer 2

Natürlich könnten zu einem bestimmten Zeitpunkt quasi mehrere Spieler gleichzeitig versuchen, ein Ticket zu ergattern. Wir wären wieder am Anfang unseres Problems angekommen. An dieser Stelle greifen wir in Firebase zum Instrumentarium einer sogenannten „Transaction Operation“. Mit ihrer Hilfe lassen sich in Firebase (Zähl-)Variablen nebenläufig Inkrementieren oder Dekrementieren oder eben eine Reihe von Anweisungen unterbrechungsfrei ausführen. Das Resultat der Ausführung wird dem Aufrufer in einer completion callback Funktion mitgeteilt. Die atomare Operation selbst ist im Kontext einer update Funktion anzusiedeln. In Abhängigkeit vom Ticket, das ein bestimmter Client mit der completion callback Funktion ausgehändigt bekommt, weiß dieser, ob er im Raum ist oder nicht. Damit kann ein Spieler den Raum erfolgreich betreten. Alle anderen interessierten Spieler werden kontrolliert abgewiesen. Den Quellcode der Transaktions-Operation finden Sie in Listing 3 vor:

01: public void tryEnterRoom(final String nickname) {
02: 
03:     DatabaseReference ref = this.refTicket;
04:     ref.runTransaction(new Transaction.Handler() {
05: 
06:         @Override
07:         public Transaction.Result doTransaction(MutableData mutableData) {
08:             Object o = mutableData.getValue(Ticket.class);
09:             if (o == null) {
10:                 return Transaction.success(mutableData);
11:             }
12: 
13:             Ticket ticket = mutableData.getValue(Ticket.class);
14:             int ticketNumber = ticket.getTicketNumber();
15: 
16:             if (ticketNumber >= 2) {
17: 
18:                 return Transaction.abort();
19: 
20:             } else {
21:                 ticket.setTicketNumber(ticket.getTicketNumber() + 1);
22:                 mutableData.setValue(ticket);
23:                 return Transaction.success(mutableData);
24:             }
25:         }
26: 
27:         @Override
28:         public void onComplete(DatabaseError databaseError, boolean committed, DataSnapshot dataSnapshot) {
29: 
30:             // transaction completed
31:             if (committed) {
32: 
33:                 Ticket state = dataSnapshot.getValue(Ticket.class);
34:                 int ticketNumber = state.getTicketNumber();
35: 
36:                 if (ticketNumber == 1 || ticketNumber == 2) {
37: 
38:                     // let player enter into the room
39:                     String info = "Player " + nickname + " has entered!";
40:                     TicTacToeModelFirebase.this.addPlayer(nickname, ticketNumber);
41:                     Toast.makeText(TicTacToeModelFirebase.this.context, info, Toast.LENGTH_SHORT).show();
42:                 } else {
43: 
44:                     String info = "Sorry - There are still 2 players in the room!";
45:                     Toast.makeText(TicTacToeModelFirebase.this.context, info, Toast.LENGTH_SHORT).show();
46:                 }
47:             } else {
48: 
49:                 String info = "Sorry - There are still 2 players in the room!";
50:                 Toast.makeText(TicTacToeModelFirebase.this.context, info, Toast.LENGTH_SHORT).show();
51:             }
52:         }
53:     });
54: }

Beispiel 3. Transaktions-Operation zum Betreten des Spielraums (hier: Ziehen eines Tickets im Kontext einer Methode tryEnterRoom).


Da ich den Schwerpunkt der Erörterungen dieser Fallstudie auf Cloud Functions gelegt habe, finden Sie abschließend auf die Schnelle noch die wesentlichen Quellcode-Dateien der Android App vor. Um auch auf der client-seitigen App eine gewisse Struktur zu Grunde zu legen, habe ich zentrale Methoden in der Realisierung durch eine Schnittstelle festgelegt:

interface ITicTacToe {

    void setOnBoardChangedListener (OnBoardChangedListener listener);
    void setOnPlayersChangedListener(OnPlayersConfigurationChangedListener listener);

    void enterPlayer (String player);

    GameStone getStoneAt (int row, int col);
    boolean setStone(int row, int col);

    void start();
    void restart();
    void exit();
}

Die Android-App selbst wurde ebenfalls auf Basis des MVC-Entwurfsmusters entworfen. Wie ist das mit den bisherigen Erläuterungen in Einklang zu bringen? Das Original-Modell residiert doch in der Firebase Cloud. Die Android-Apps melden sich an zentralen Daten dieses Modells bzgl. der Änderung von Werten an, um diese aktuell und geeignet client-seitig darstellen zu können. Ich würde nicht zu einer Formulierung Kopie des Cloud-Modells greifen, da vor allem die Logik-Funktionen in den Apps überhaupt nicht vorhanden sind. Die Daten des Modells (Spielbrett, Namen der Spieler, Spielzustand) müssen zeitnah auf den Apps gespiegelt sein, um den zentralen Leitfaden eine App „Keep Your App Responsive“ zu gewährleisten. Ein weiterer wesentlicher Aspekt ist, dass es in meinem Entwurf Aufgabe der Apps ist, falsche Benutzereingaben abzuweisen.

Auf Grund dieser Vorüberlegungen schließe ich die Fallstudie mit dem Quellcode der

  • MainActivity (Layout und Code-Behind) – Listing 4 und Listing 5

  • client-seitiges Spielgelbild der Daten (Modell) – Listing 6 und

  • View für das Spielbrett (Ansicht) – Listing 7

ab. Der komplette Quellcode ist wie gehabt auf GitHub verfügbar. Dieses Mal sind es zwei Repositories – eines für die Android-App und ein zweites für die Cloud-Functions.

001: <?xml version="1.0" encoding="utf-8"?>
002: 
003: <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
004:     xmlns:app="http://schemas.android.com/apk/res-auto"
005:     xmlns:tools="http://schemas.android.com/tools"
006:     android:layout_width="match_parent"
007:     android:layout_height="match_parent"
008:     tools:context="peterloos.de.anothertictactoe.activities.TicTacToeActivity">
009: 
010:     <android.support.v7.widget.Toolbar
011:         android:id="@+id/main_toolbar"
012:         android:layout_width="match_parent"
013:         android:layout_height="wrap_content"
014:         android:layout_alignParentTop="true"
015:         android:background="?attr/colorPrimaryDark"
016:         android:minHeight="?attr/actionBarSize"
017:         android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
018:         app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />
019: 
020:     <LinearLayout
021:         android:id="@+id/linearLayoutNickname"
022:         android:layout_width="match_parent"
023:         android:layout_height="wrap_content"
024:         android:layout_below="@+id/main_toolbar"
025:         android:layout_marginEnd="5dp"
026:         android:layout_marginLeft="5dp"
027:         android:layout_marginRight="5dp"
028:         android:layout_marginStart="5dp"
029:         android:orientation="horizontal">
030: 
031:         <TextView
032:             android:id="@+id/textviewNickname"
033:             android:layout_width="wrap_content"
034:             android:layout_height="wrap_content"
035:             android:text="@string/nickname"
036:             android:textSize="18sp"
037:             android:textStyle="normal|bold" />
038: 
039:         <EditText
040:             android:id="@+id/edittextNickname"
041:             android:layout_width="match_parent"
042:             android:layout_height="wrap_content"
043:             android:inputType="text"
044:             android:text="" />
045: 
046:     </LinearLayout>
047: 
048:     <LinearLayout
049:         android:id="@+id/register_unregister"
050:         android:layout_width="match_parent"
051:         android:layout_height="wrap_content"
052:         android:layout_below="@+id/linearLayoutNickname"
053:         android:layout_marginEnd="5dp"
054:         android:layout_marginLeft="5dp"
055:         android:layout_marginRight="5dp"
056:         android:layout_marginStart="5dp"
057:         android:orientation="horizontal">
058: 
059:         <Button
060:             android:id="@+id/buttonEnter"
061:             android:layout_width="0dp"
062:             android:layout_height="wrap_content"
063:             android:layout_weight="1"
064:             android:text="@string/enter"
065:             tools:ignore="ButtonStyle" />
066: 
067:         <Button
068:             android:id="@+id/buttonExit"
069:             android:layout_width="0dp"
070:             android:layout_height="wrap_content"
071:             android:layout_weight="1"
072:             android:text="@string/exit"
073:             tools:ignore="ButtonStyle" />
074: 
075:     </LinearLayout>
076: 
077:     <LinearLayout
078:         android:id="@+id/linearlayoutPlayers"
079:         android:layout_width="match_parent"
080:         android:layout_height="wrap_content"
081:         android:layout_below="@+id/register_unregister"
082:         android:layout_marginEnd="5dp"
083:         android:layout_marginLeft="5dp"
084:         android:layout_marginRight="5dp"
085:         android:layout_marginStart="5dp"
086:         android:orientation="horizontal">
087: 
088:         <TextView
089:             android:id="@+id/textviewPlayer1"
090:             android:layout_width="0dp"
091:             android:layout_height="wrap_content"
092:             android:layout_gravity="start"
093:             android:layout_marginEnd="5dp"
094:             android:layout_marginRight="5dp"
095:             android:layout_weight="1"
096:             android:padding="5dp"
097:             android:textAlignment="textStart"
098:             android:textSize="18sp" />
099: 
100:         <TextView
101:             android:id="@+id/textviewPlayer2"
102:             android:layout_width="0dp"
103:             android:layout_height="wrap_content"
104:             android:layout_gravity="end"
105:             android:layout_marginLeft="5dp"
106:             android:layout_marginStart="5dp"
107:             android:layout_weight="1"
108:             android:padding="5dp"
109:             android:textAlignment="textEnd"
110:             android:textSize="18sp" />
111: 
112:     </LinearLayout>
113: 
114:     <LinearLayout
115:         android:id="@+id/linearlayoutScores"
116:         android:layout_width="match_parent"
117:         android:layout_height="wrap_content"
118:         android:layout_below="@+id/linearlayoutPlayers"
119:         android:layout_marginEnd="5dp"
120:         android:layout_marginLeft="5dp"
121:         android:layout_marginRight="5dp"
122:         android:layout_marginStart="5dp"
123:         android:orientation="horizontal">
124: 
125:         <TextView
126:             android:id="@+id/textviewScore1"
127:             android:layout_width="0dp"
128:             android:layout_height="wrap_content"
129:             android:layout_gravity="start"
130:             android:layout_weight="1"
131:             android:padding="5dp"
132:             android:text="@string/score_0"
133:             android:textAlignment="textStart"
134:             android:textStyle="normal|italic" />
135: 
136:         <TextView
137:             android:id="@+id/textviewScore2"
138:             android:layout_width="0dp"
139:             android:layout_height="wrap_content"
140:             android:layout_gravity="end"
141:             android:layout_weight="1"
142:             android:padding="5dp"
143:             android:text="@string/score_0"
144:             android:textAlignment="textEnd"
145:             android:textStyle="normal|italic" />
146: 
147:     </LinearLayout>
148: 
149:     <peterloos.de.anothertictactoe.views.TicTacToeView
150:         android:id="@+id/tictactoeView"
151:         android:layout_width="match_parent"
152:         android:layout_height="match_parent"
153:         android:layout_above="@+id/footer"
154:         android:layout_below="@+id/linearlayoutScores"
155:         android:layout_marginEnd="5dp"
156:         android:layout_marginLeft="5dp"
157:         android:layout_marginRight="5dp"
158:         android:layout_marginStart="5dp" />
159: 
160:     <LinearLayout
161:         android:id="@+id/footer"
162:         android:layout_width="match_parent"
163:         android:layout_height="wrap_content"
164:         android:layout_alignParentBottom="true"
165:         android:layout_marginEnd="5dp"
166:         android:layout_marginLeft="5dp"
167:         android:layout_marginRight="5dp"
168:         android:layout_marginStart="5dp"
169:         android:orientation="horizontal">
170: 
171:         <Button
172:             android:id="@+id/buttonStart"
173:             android:layout_width="0dp"
174:             android:layout_height="wrap_content"
175:             android:layout_weight="1"
176:             android:text="@string/start"
177:             tools:ignore="ButtonStyle" />
178: 
179:         <Button
180:             android:id="@+id/buttonRestart"
181:             android:layout_width="0dp"
182:             android:layout_height="wrap_content"
183:             android:layout_weight="1"
184:             android:text="@string/another_game"
185:             tools:ignore="ButtonStyle" />
186: 
187:     </LinearLayout>
188: 
189: </RelativeLayout>

Beispiel 4. MainActivity der Android-App – hier: Klasse TicTacToeActivity (Layout).


001: package peterloos.de.anothertictactoe.activities;
002: 
003: import android.content.res.Resources;
004: import android.graphics.Color;
005: import android.support.v7.app.AppCompatActivity;
006: import android.os.Bundle;
007: import android.support.v7.widget.Toolbar;
008: import android.view.View;
009: import android.widget.Button;
010: import android.widget.EditText;
011: import android.widget.TextView;
012: 
013: import peterloos.de.anothertictactoe.R;
014: import peterloos.de.anothertictactoe.interfaces.ITicTacToe;
015: import peterloos.de.anothertictactoe.interfaces.OnPlayersConfigurationChangedListener;
016: import peterloos.de.anothertictactoe.models.TicTacToeModelFirebase;
017: import peterloos.de.anothertictactoe.views.TicTacToeView;
018: 
019: public class TicTacToeActivity
020:         extends AppCompatActivity
021:         implements View.OnClickListener, OnPlayersConfigurationChangedListener {
022: 
023:     // UI controls
024:     private Button buttonEnter;
025:     private Button buttonExit;
026:     private Button buttonStart;
027:     private Button buttonRestart;
028: 
029:     private EditText edittextNickname;
030:     private TextView textviewPlayer1;
031:     private TextView textviewPlayer2;
032:     private TextView textviewScore1;
033:     private TextView textviewScore2;
034: 
035:     private TicTacToeView view;
036:     private Toolbar toolbar;
037: 
038:     private Resources res;
039:     private int red;
040:     private int green;
041:     private int blue;
042:     private int lightgrey;
043: 
044:     // data model
045:     private ITicTacToe model;
046: 
047:     @Override
048:     protected void onCreate(Bundle savedInstanceState) {
049:         super.onCreate(savedInstanceState);
050:         this.setContentView(R.layout.activity_tic_tac_toe);
051: 
052:         // setup toolbar
053:         this.toolbar = this.findViewById(R.id.main_toolbar);
054:         this.toolbar.setTitleTextColor(Color.WHITE);
055:         this.toolbar.setTitle("Another Tic-Tac-Toe");
056:         this.toolbar.setSubtitle("Simple Multiplayer Game");
057:         this.setSupportActionBar(this.toolbar);
058: 
059:         // retrieve references of controls
060:         this.view = this.findViewById(R.id.tictactoeView);
061:         this.buttonEnter = this.findViewById(R.id.buttonEnter);
062:         this.buttonExit = this.findViewById(R.id.buttonExit);
063:         this.edittextNickname = this.findViewById(R.id.edittextNickname);
064:         this.textviewPlayer1 = this.findViewById(R.id.textviewPlayer1);
065:         this.textviewPlayer2 = this.findViewById(R.id.textviewPlayer2);
066:         this.textviewScore1 = this.findViewById(R.id.textviewScore1);
067:         this.textviewScore2 = this.findViewById(R.id.textviewScore2);
068:         this.buttonStart = this.findViewById(R.id.buttonStart);
069:         this.buttonRestart = this.findViewById(R.id.buttonRestart);
070: 
071:         // register event handler
072:         this.buttonEnter.setOnClickListener(this);
073:         this.buttonExit.setOnClickListener(this);
074:         this.buttonStart.setOnClickListener(this);
075:         this.buttonRestart.setOnClickListener(this);
076: 
077:         // initialize textview's upon creation
078:         this.textviewPlayer1.setText("");
079:         this.textviewPlayer2.setText("");
080:         this.textviewScore1.setText(R.string.score_0);
081:         this.textviewScore2.setText(R.string.score_0);
082: 
083:         this.res = this.getResources();
084:         this.red = this.res.getColor(R.color.Red);
085:         this.green = this.res.getColor(R.color.LightGreen);
086:         this.blue = this.res.getColor(R.color.LightBlue);
087:         this.lightgrey = this.res.getColor(R.color.VeryLightGrey);
088: 
089:         this.textviewPlayer1.setBackgroundColor(this.lightgrey);
090:         this.textviewPlayer2.setBackgroundColor(this.lightgrey);
091: 
092:         // create model
093:         this.model = new TicTacToeModelFirebase(this.getApplicationContext());
094:         this.model.setOnPlayersChangedListener(this);
095:         this.view.setTicTacToeModel(this.model);
096:     }
097: 
098:     @Override
099:     public void onClick(View view) {
100: 
101:         if (view == this.buttonEnter) {
102: 
103:             String nickname = this.edittextNickname.getText().toString();
104:             if (!nickname.equals("")) {
105: 
106:                 this.model.enterPlayer(nickname);
107:                 this.edittextNickname.setText("");
108:             }
109:         } else if (view == this.buttonStart) {
110: 
111:             this.model.start();
112: 
113:         } else if (view == this.buttonRestart) {
114: 
115:             this.model.restart();
116: 
117:         } else if (view == this.buttonExit) {
118: 
119:             this.model.exit();
120:         }
121:     }
122: 
123:     /*
124:      *  implementation of interface 'OnPlayersConfigurationChangedListener'
125:      */
126: 
127:     @Override
128:     public void playersActivityStateChanged(int whichPlayer, boolean active) {
129: 
130:         if (whichPlayer == 0) {
131: 
132:             if (active) {
133: 
134:                 this.textviewPlayer1.setBackgroundColor(this.green);
135:                 this.textviewPlayer2.setBackgroundColor(this.red);
136:             } else {
137: 
138:                 this.textviewPlayer1.setBackgroundColor(this.red);
139:                 this.textviewPlayer2.setBackgroundColor(this.green);
140:             }
141:         } else if (whichPlayer == 1) {
142: 
143:             if (active) {
144: 
145:                 this.textviewPlayer1.setBackgroundColor(this.green);
146:                 this.textviewPlayer2.setBackgroundColor(this.red);
147:             } else {
148: 
149:                 this.textviewPlayer1.setBackgroundColor(this.red);
150:                 this.textviewPlayer2.setBackgroundColor(this.green);
151:             }
152:         }
153:     }
154: 
155:     @Override
156:     public void currentPlayersNameChanged(String name) {
157: 
158:         if (name == null || name.equals("")) {
159: 
160:             this.textviewPlayer1.setText("");
161:             this.textviewPlayer1.setBackgroundColor(this.lightgrey);
162:         }
163:         else {
164: 
165:             this.textviewPlayer1.setText(name);
166:             this.textviewPlayer1.setBackgroundColor(this.blue);
167:         }
168:     }
169: 
170:     @Override
171:     public void otherPlayersNameChanged(String name) {
172: 
173:         if (name == null || name.equals("")) {
174: 
175:             this.textviewPlayer2.setText("");
176:             this.textviewPlayer2.setBackgroundColor(this.lightgrey);
177:         }
178:         else {
179: 
180:             this.textviewPlayer2.setText(name);
181:             this.textviewPlayer2.setBackgroundColor(this.blue);
182:         }
183:     }
184: 
185:     @Override
186:     public void scoreChanged(int score, boolean atLeftSide) {
187: 
188:         if (atLeftSide) {
189:             this.textviewScore1.setText("Score: " + Integer.toString(score));
190:         } else {
191:             this.textviewScore2.setText("Score: " + Integer.toString(score));
192:         }
193:     }
194: 
195:     @Override
196:     public void clearPlayersState() {
197: 
198:         this.textviewPlayer1.setBackgroundColor(this.blue);
199:         this.textviewPlayer2.setBackgroundColor(this.blue);
200:     }
201: }

Beispiel 5. MainActivity der Android-App – hier: Klasse TicTacToeActivity (Code-Behind).


001: package peterloos.de.anothertictactoe.models;
002: 
003: import android.content.Context;
004: import android.util.Log;
005: import android.widget.Toast;
006: 
007: import com.google.firebase.database.ChildEventListener;
008: import com.google.firebase.database.DataSnapshot;
009: import com.google.firebase.database.DatabaseError;
010: import com.google.firebase.database.DatabaseReference;
011: import com.google.firebase.database.DatabaseReference.CompletionListener;
012: import com.google.firebase.database.FirebaseDatabase;
013: import com.google.firebase.database.MutableData;
014: import com.google.firebase.database.Transaction;
015: import com.google.firebase.database.ValueEventListener;
016: 
017: import java.util.HashMap;
018: 
019: import peterloos.de.anothertictactoe.Globals;
020: import peterloos.de.anothertictactoe.interfaces.ITicTacToe;
021: import peterloos.de.anothertictactoe.interfaces.OnBoardChangedListener;
022: import peterloos.de.anothertictactoe.interfaces.OnPlayersConfigurationChangedListener;
023: 
024: import static peterloos.de.anothertictactoe.Globals.Dimension;
025: 
026: public class TicTacToeModelFirebase implements ITicTacToe {
027: 
028:     // cloud states
029:     private final String GameInit = "";
030:     private final String GameIdle = "GameIdle";
031:     private final String GameActive = "GameActive";
032:     private final String GameOver = "GameOver";
033: 
034:     // game commands
035:     private final String GameCommandClear = "clear";
036:     private final String GameCommandStart = "start";
037: 
038:     // Firebase utils
039:     private FirebaseDatabase database;
040:     private DatabaseReference refPlayers;
041:     private DatabaseReference refBoard;
042:     private DatabaseReference refCommand;
043:     private DatabaseReference refStatus;
044:     private DatabaseReference refTicket;
045: 
046:     // general member data
047:     private Context context;
048: 
049:     // game utils
050:     private HashMap<String, String> board;
051:     private AppState appState;
052:     private GameStone stone;
053: 
054:     // players utils
055:     private String currentPlayer;
056:     private String otherPlayer;
057:     private String currentPlayerKey;
058: 
059:     // listeners
060:     private OnBoardChangedListener boardListener;
061:     private OnPlayersConfigurationChangedListener playersListener;
062: 
063:     // c'tor
064:     public TicTacToeModelFirebase(Context context) {
065: 
066:         this.context = context;
067: 
068:         this.appState = AppState.Idle;
069:         this.board = new HashMap<>();
070: 
071:         // init access to database
072:         this.database = FirebaseDatabase.getInstance();
073:         this.refPlayers = database.getReference("players");
074:         this.refBoard = database.getReference("board");
075:         this.refCommand = this.database.getReference("control/command");
076:         this.refStatus = this.database.getReference("control/status");
077:         this.refTicket = this.database.getReference("control/ticket");
078: 
079:         this.refPlayers.addChildEventListener(this.childEventListener);
080:         this.refBoard.addValueEventListener(this.boardValueEventListener);
081:         this.refStatus.addValueEventListener(this.controlValueEventListener);
082: 
083:         this.currentPlayer = "";
084:         this.currentPlayerKey = "";
085:         this.otherPlayer = "";
086: 
087:         this.stone = GameStone.Empty;
088: 
089:         this.initializeBoardInternal();
090:     }
091: 
092:     // implementation of interface 'ITicTacToe'
093: 
094:     @Override
095:     public void setOnBoardChangedListener(OnBoardChangedListener listener) {
096: 
097:         this.boardListener = listener;
098:     }
099: 
100:     @Override
101:     public void setOnPlayersChangedListener(OnPlayersConfigurationChangedListener listener) {
102: 
103:         this.playersListener = listener;
104:     }
105: 
106:     @Override
107:     public void enterPlayer(String name) {
108: 
109:         if (this.currentPlayer.equals("")) {
110: 
111:             this.tryEnterRoom(name);
112:         }
113:     }
114: 
115:     @Override
116:     public void start() {
117: 
118:         // trigger 'start' command in cloud
119:         this.emitComand(GameCommandStart);
120:     }
121: 
122:     @Override
123:     public void restart() {
124: 
125:         // trigger 'clear' command in cloud
126:         this.emitComand(GameCommandClear);
127:     }
128: 
129:     @Override
130:     public void exit() {
131: 
132:         this.emitComand(GameCommandClear);   // send "clear" command to firebase cloud
133:         this.deleteAllPlayers();   // remove all players
134:         this.resetTicketNumber();  // reset ticket number to zero
135:         this.clearStatus();        // clear game status
136:     }
137: 
138:     private ValueEventListener boardValueEventListener = new ValueEventListener() {
139:         @Override
140:         public void onDataChange(DataSnapshot dataSnapshot) {
141: 
142:             TicTacToeModelFirebase.this.evaluateBoardSnapshot(dataSnapshot);
143:         }
144: 
145:         @Override
146:         public void onCancelled(DatabaseError error) {
147:             Log.e(Globals.Tag, "Failed to read value.", error.toException());
148:         }
149:     };
150: 
151:     private ValueEventListener controlValueEventListener = new ValueEventListener() {
152:         @Override
153:         public void onDataChange(DataSnapshot dataSnapshot) {
154: 
155:             TicTacToeModelFirebase.this.evaluateStatusSnapshot(dataSnapshot);
156:         }
157: 
158:         @Override
159:         public void onCancelled(DatabaseError error) {
160:             Log.e(Globals.Tag, "Failed to read value.", error.toException());
161:         }
162:     };
163: 
164:     // private helper methods
165:     private void evaluateStatusSnapshot(DataSnapshot dataSnapshot) {
166: 
167:         if (!dataSnapshot.exists()) {
168:             return;
169:         }
170: 
171:         Status status = dataSnapshot.getValue(Status.class);
172: 
173:         switch (status.getId()) {
174: 
175:             case GameInit:
176:                 Log.v(Globals.Tag, "No game state yet set - no error");
177:                 break;
178: 
179:             case GameIdle:
180:                 Log.v(Globals.Tag, "Game state reset to idle");
181:                 break;
182: 
183:             case GameActive:
184: 
185:                 // check for key of next player
186:                 if (this.currentPlayerKey.equals("") || status.getParameter1().equals("")) {
187: 
188:                     Log.v(Globals.Tag, "Internal ERROR: Unexpected Game State ===> " + status.toString());
189:                     break;
190:                 }
191: 
192:                 // look at key of next player
193:                 if (this.currentPlayerKey.equals(status.getParameter1())) {
194: 
195:                     String s = String.format("Player with key %s should *play* now", status.getParameter1());
196:                     Log.v(Globals.Tag, s);
197: 
198:                     this.appState = AppState.Active;
199:                     this.changePlayersActivityState(0, true);
200:                 } else {
201: 
202:                     String s = String.format("Player with key %s should *wait* now", status.getParameter1());
203:                     Log.v(Globals.Tag, s);
204: 
205:                     this.appState = AppState.Passive;
206:                     this.changePlayersActivityState(1, false);
207:                 }
208:                 break;
209: 
210:             case GameOver:
211: 
212:                 // check key of player, who won the game
213:                 if (this.currentPlayerKey.equals("")) {
214: 
215:                     Log.v(Globals.Tag, "Internal ERROR: Unexpected Game State ===> " + status.toString());
216:                     break;
217:                 }
218: 
219:                 if (!status.getParameter1().equals("")) {
220: 
221:                     // game is over, the first parameter contains the key of the winner
222:                     Log.v(Globals.Tag, "GameOver => " + status.toString());
223:                     int score = Integer.valueOf(status.getParameter2());
224: 
225:                     // place a toast for both winner and loser
226:                     if (this.currentPlayerKey.equals(status.getParameter1())) {
227: 
228:                         String toast = String.format("Yeaah %s you're the winner!", this.currentPlayer);
229:                         Toast.makeText(this.context, toast, Toast.LENGTH_SHORT).show();
230:                         this.changeScore(score, true);
231:                     } else {
232: 
233:                         String toast = String.format("Sorry %s you've lost the game!", this.currentPlayer);
234:                         Toast.makeText(this.context, toast, Toast.LENGTH_SHORT).show();
235:                         this.changeScore(score, false);
236:                     }
237:                 } else {
238: 
239:                     // game is over, first parameter is empty: game ended with a draw
240:                     String message = "Game over --- it's a draw !!!";
241:                     Toast.makeText(this.context, message, Toast.LENGTH_SHORT).show();
242:                 }
243: 
244:                 this.clearPlayersState();
245:                 this.appState = AppState.Idle;
246:                 break;
247: 
248:             default:
249: 
250:                 String s = String.format("Internal ERROR => Status: %s", status.getId());
251:                 Log.v(Globals.Tag, s);
252:                 break;
253:         }
254:     }
255: 
256:     @Override
257:     public GameStone getStoneAt(int row, int col) {
258: 
259:         String key = this.cellToKey(row, col);
260:         String value = this.board.get(key);
261:         return GameStone.valueOf(value);
262:     }
263: 
264:     @Override
265:     public boolean setStone(int row, int col) {
266: 
267:         String key = this.cellToKey(row, col);
268: 
269:         // ignore this request - current game over or not initialized
270:         if (this.appState == AppState.Idle || this.appState == AppState.PendingForNextCloudState)
271:             return false;
272: 
273:         // it's not your turn
274:         if (this.appState == AppState.Passive) {
275: 
276:             String msg = String.format("It's %s's turn!", this.otherPlayer);
277:             Toast.makeText(this.context, msg, Toast.LENGTH_SHORT).show();
278:             return false;
279:         }
280: 
281:         // is there already a stone, ignore call
282:         if (!this.isFieldEmpty(key))
283:             return false;
284: 
285:         // accepting stone - set view into 'passive' state ...
286:         this.appState = AppState.PendingForNextCloudState;
287: 
288:         // ...  and set stone on (remote) board
289:         this.setSingleStoneRemote(row, col, this.stone);
290: 
291:         return true;
292:     }
293: 
294:     private void evaluateBoardSnapshot(DataSnapshot dataSnapshot) {
295: 
296:         if (!dataSnapshot.exists()) {
297:             return;
298:         }
299: 
300:         for (DataSnapshot data : dataSnapshot.getChildren()) {
301: 
302:             Log.d(Globals.Tag, "    Key:   " + data.getKey());
303:             for (DataSnapshot subData : data.getChildren()) {
304: 
305:                 if (subData.getKey().equals("col1")) {
306: 
307:                     Cell cell = subData.getValue(Cell.class);
308:                     this.onCellChanged(data.getKey(), "col1", cell.getState());
309:                 } else if (subData.getKey().equals("col2")) {
310: 
311:                     Cell cell = subData.getValue(Cell.class);
312:                     this.onCellChanged(data.getKey(), "col2", cell.getState());
313:                 } else if (subData.getKey().equals("col3")) {
314: 
315:                     Cell cell = subData.getValue(Cell.class);
316:                     this.onCellChanged(data.getKey(), "col3", cell.getState());
317:                 }
318:             }
319:         }
320:     }
321: 
322:     private void onCellChanged(String row, String col, String stone) {
323: 
324:         String key = this.cellToKey(row, col);
325:         String value = this.board.get(key);
326: 
327:         GameStone oldStone = GameStone.valueOf(value);
328:         GameStone newStone = GameStone.valueOf(stone);
329: 
330:         if (oldStone != newStone) {
331: 
332:             // enter new stone into hash map of model
333:             this.board.put(key, stone);
334: 
335:             // fire notification according to state of board
336:             this.changeBoard();
337:         }
338:     }
339: 
340:     private String cellToKey(int row, int col) {
341: 
342:         // assertion: row and col are in the range 1..3
343:         return Integer.toString((row - 1) * Dimension + col);
344:     }
345: 
346:     private String cellToKey(String srow, String scol) {
347: 
348:         int row = this.rowToInt(srow);
349:         int col = this.colToInt(scol);
350:         return this.cellToKey(row, col);
351:     }
352: 
353:     private boolean isFieldEmpty(String key) {
354: 
355:         String value = this.board.get(key);
356:         return value.equals(GameStone.Empty.toString());
357:     }
358: 
359:     private int rowToInt(String row) {
360: 
361:         return row.charAt(3) - '0';
362:     }
363: 
364:     private int colToInt(String row) {
365: 
366:         return row.charAt(3) - '0';
367:     }
368: 
369:     private void setSingleStoneRemote(int r, int c, GameStone stone) {
370: 
371:         String row = "row" + r;
372:         String col = "col" + c;
373:         Cell cell = new Cell(stone.toString());
374:         this.refBoard.child(row).child(col).setValue(cell);
375:     }
376: 
377:     public void tryEnterRoom(final String nickname) {
378: 
379:         DatabaseReference ref = this.refTicket;
380:         ref.runTransaction(new Transaction.Handler() {
381: 
382:             @Override
383:             public Transaction.Result doTransaction(MutableData mutableData) {
384:                 Object o = mutableData.getValue(Ticket.class);
385:                 if (o == null) {
386:                     return Transaction.success(mutableData);
387:                 }
388: 
389:                 Ticket ticket = mutableData.getValue(Ticket.class);
390:                 int ticketNumber = ticket.getTicketNumber();
391: 
392:                 if (ticketNumber >= 2) {
393: 
394:                     return Transaction.abort();
395: 
396:                 } else {
397:                     ticket.setTicketNumber(ticket.getTicketNumber() + 1);
398:                     mutableData.setValue(ticket);
399:                     return Transaction.success(mutableData);
400:                 }
401:             }
402: 
403:             @Override
404:             public void onComplete(DatabaseError databaseError, boolean committed, DataSnapshot dataSnapshot) {
405: 
406:                 // transaction completed
407:                 if (committed) {
408: 
409:                     Ticket state = dataSnapshot.getValue(Ticket.class);
410:                     int ticketNumber = state.getTicketNumber();
411: 
412:                     if (ticketNumber == 1 || ticketNumber == 2) {
413: 
414:                         // let player enter into the room
415:                         String info = "Player " + nickname + " has entered!";
416:                         TicTacToeModelFirebase.this.addPlayer(nickname, ticketNumber);
417:                         Toast.makeText(TicTacToeModelFirebase.this.context, info, Toast.LENGTH_SHORT).show();
418:                     } else {
419: 
420:                         String info = "Sorry - There are still 2 players in the room!";
421:                         Toast.makeText(TicTacToeModelFirebase.this.context, info, Toast.LENGTH_SHORT).show();
422:                     }
423:                 } else {
424: 
425:                     String info = "Sorry - There are still 2 players in the room!";
426:                     Toast.makeText(TicTacToeModelFirebase.this.context, info, Toast.LENGTH_SHORT).show();
427:                 }
428:             }
429:         });
430:     }
431: 
432:     private void initializeBoardInternal() {
433: 
434:         // initialize internal hash map of stones
435:         for (int row = 1; row <= Dimension; row++) {
436:             for (int col = 1; col <= Dimension; col++) {
437:                 String key = this.cellToKey(row, col);
438:                 this.board.put(key, GameStone.Empty.toString());
439:             }
440:         }
441:     }
442: 
443:     // =============================================================================================
444: 
445:     /*
446:      *   firebase specific functions
447:      */
448: 
449:     private void emitComand(final String cmd) {
450:         this.refCommand.setValue(cmd, new CompletionListener() {
451:             @Override
452:             public void onComplete(DatabaseError databaseError, DatabaseReference databaseReference) {
453: 
454:                 Log.v(Globals.Tag, "data base: control/command => " + cmd);
455:             }
456:         });
457:     }
458: 
459:     private void addPlayer(String name, int ticketNumber) {
460: 
461:         DatabaseReference playersRef = this.refPlayers.push();
462: 
463:         this.currentPlayer = name;
464:         this.currentPlayerKey = playersRef.getKey();
465: 
466:         Player player = new Player();
467:         player.setName(name);
468:         player.setKey(this.currentPlayerKey);
469: 
470:         this.stone = (ticketNumber == 1) ? GameStone.X : GameStone.O;
471:         player.setStone(this.stone.toString());
472: 
473:         player.setScore(0);
474:         playersRef.setValue(player);
475:     }
476: 
477:     private void deleteAllPlayers() {
478:         this.refPlayers.removeValue(new CompletionListener() {
479: 
480:             @Override
481:             public void onComplete(DatabaseError databaseError, DatabaseReference databaseReference) {
482: 
483:                 Log.v(Globals.Tag, "data base: players => null");
484:             }
485:         });
486:     }
487: 
488:     private void resetTicketNumber() {
489:         this.refTicket.child("ticketNumber").setValue(0, new CompletionListener() {
490:             @Override
491:             public void onComplete(DatabaseError databaseError, DatabaseReference databaseReference) {
492: 
493:                 Log.v(Globals.Tag, "data base: control/ticket/ticketNumber => 0");
494:             }
495:         });
496:     }
497: 
498:     private void clearStatus() {
499:         Status empty = new Status();
500:         this.refStatus.setValue(empty, new CompletionListener() {
501:             @Override
502:             public void onComplete(DatabaseError databaseError, DatabaseReference databaseReference) {
503: 
504:                 Log.v(Globals.Tag, "data base: control/status => { id : \"\", par1 : \"\", par2 : \"\" } ");
505:             }
506:         });
507:     }
508: 
509:     // =============================================================================================
510: 
511:     private ChildEventListener childEventListener = new ChildEventListener() {
512: 
513:         @Override
514:         public void onChildAdded(DataSnapshot dataSnapshot, String previousChildName) {
515: 
516:             // a new player has been added
517:             Player player = dataSnapshot.getValue(Player.class);
518:             Log.d(Globals.Tag, "onChildAdded: " + player.toString() + " [" + dataSnapshot.getKey() + "]");
519: 
520:             if (TicTacToeModelFirebase.this.playersListener != null) {
521: 
522:                 if (TicTacToeModelFirebase.this.currentPlayer.equals(player.getName())) {
523: 
524:                     TicTacToeModelFirebase.this.playersListener.currentPlayersNameChanged(player.getName());
525:                 } else {
526: 
527:                     // a second player must have entered the room
528:                     TicTacToeModelFirebase.this.otherPlayer = player.getName();
529:                     TicTacToeModelFirebase.this.playersListener.otherPlayersNameChanged(TicTacToeModelFirebase.this.otherPlayer);
530:                 }
531:             }
532:         }
533: 
534:         @Override
535:         public void onChildChanged(DataSnapshot dataSnapshot, String previousChildName) {
536:         }
537: 
538:         @Override
539:         public void onChildRemoved(DataSnapshot dataSnapshot) {
540: 
541:             // removing a player
542:             Player player = dataSnapshot.getValue(Player.class);
543:             Log.d(Globals.Tag, "onChildRemoved: " + player.toString() + " [" + dataSnapshot.getKey() + "]");
544: 
545:             if (TicTacToeModelFirebase.this.playersListener != null) {
546: 
547:                 TicTacToeModelFirebase.this.currentPlayer = "";
548:                 TicTacToeModelFirebase.this.currentPlayerKey = "";
549:                 TicTacToeModelFirebase.this.otherPlayer = "";
550: 
551:                 TicTacToeModelFirebase.this.playersListener.currentPlayersNameChanged("");
552:                 TicTacToeModelFirebase.this.playersListener.otherPlayersNameChanged("");
553: 
554:                 TicTacToeModelFirebase.this.playersListener.scoreChanged(0, false);
555:                 TicTacToeModelFirebase.this.playersListener.scoreChanged(0, true);
556:             }
557: 
558:             TicTacToeModelFirebase.this.appState = AppState.Idle;
559:         }
560: 
561:         @Override
562:         public void onChildMoved(DataSnapshot dataSnapshot, String previousChildName) {
563:         }
564: 
565:         @Override
566:         public void onCancelled(DatabaseError databaseError) {
567:         }
568:     };
569: 
570:     // =============================================================================================
571: 
572:     /*
573:      *   helper functions
574:      */
575: 
576:     private void changePlayersActivityState(int whichPlayer, boolean playersState) {
577: 
578:         if (this.playersListener != null) {
579: 
580:             this.playersListener.playersActivityStateChanged(whichPlayer, playersState);
581:         }
582:     }
583: 
584:     private void changeBoard() {
585: 
586:         if (this.boardListener != null) {
587: 
588:             this.boardListener.boardChanged();
589:         }
590:     }
591: 
592:     private void changeScore(int score, boolean atLeftSide) {
593: 
594:         if (this.playersListener != null) {
595: 
596:             this.playersListener.scoreChanged(score, atLeftSide);
597:         }
598:     }
599: 
600:     private void clearPlayersState() {
601: 
602:         if (TicTacToeModelFirebase.this.playersListener != null) {
603: 
604:             this.playersListener.clearPlayersState();
605:         }
606:     }
607: 
608: }

Beispiel 6. Modell-Klasse der Android-App.


001: package peterloos.de.anothertictactoe.views;
002: 
003: import android.app.Activity;
004: import android.content.Context;
005: import android.graphics.Canvas;
006: import android.graphics.Color;
007: import android.graphics.Paint;
008: import android.graphics.Rect;
009: import android.support.annotation.Nullable;
010: import android.util.AttributeSet;
011: import android.util.DisplayMetrics;
012: import android.view.MotionEvent;
013: import android.view.View;
014: 
015: import peterloos.de.anothertictactoe.interfaces.ITicTacToe;
016: import peterloos.de.anothertictactoe.interfaces.OnBoardChangedListener;
017: import peterloos.de.anothertictactoe.models.GameStone;
018: 
019: import static peterloos.de.anothertictactoe.Globals.Dimension;
020: 
021: public class TicTacToeView extends View implements View.OnTouchListener, OnBoardChangedListener {
022: 
023:     // model for this view
024:     private ITicTacToe model;
025: 
026:     // drawing utils
027:     private Paint paintLine;
028:     private Paint paintCircle;
029:     private Paint paintCross;
030: 
031:     private int mmInPxHorizontal;
032:     private int mmInPxVertical;
033: 
034:     private int top;
035:     private int left;
036:     private int length;
037:     private int distance;
038:     private Rect[][] helperRectangles;
039: 
040:     private int red;
041:     private int blue;
042:     private int black;
043: 
044:     private boolean firstOnDraw;
045: 
046:     // c'tor
047:     public TicTacToeView(Context context, @Nullable AttributeSet attrs) {
048:         super(context, attrs);
049: 
050:         if (this.isInEditMode()) {
051:             // nothing to do if view is currently in edit mode
052:             return;
053:         }
054: 
055:         // need some view metrics to layout UI elements
056:         DisplayMetrics metrics = new DisplayMetrics();
057:         Activity activity = ((Activity) this.getContext());
058:         activity.getWindowManager().getDefaultDisplay().getMetrics(metrics);
059:         this.mmInPxHorizontal = this.pxToMMHorizontal(metrics);
060:         this.mmInPxVertical = this.pxToMMVertical(metrics);
061: 
062:         // setup colors
063:         this.red = Color.parseColor("#FF1E12");
064:         this.blue = Color.parseColor("#00C9FC");
065:         this.black = Color.parseColor("#333333");
066: 
067:         // setup painting objects
068:         this.paintLine = new Paint();
069:         this.paintLine.setStyle(Paint.Style.FILL_AND_STROKE);
070:         this.paintLine.setStrokeWidth(this.mmInPxHorizontal * 2);
071:         this.paintLine.setColor(Color.WHITE);
072: 
073:         this.paintCircle = new Paint();
074:         this.paintCircle.setColor(this.blue);
075:         this.paintCircle.setStrokeWidth(this.mmInPxHorizontal * 2);
076:         this.paintCircle.setStyle(Paint.Style.STROKE);
077: 
078:         this.paintCross = new Paint();
079:         this.paintCross.setStyle(Paint.Style.FILL_AND_STROKE);
080:         this.paintCross.setStrokeWidth(this.mmInPxHorizontal * 2);
081:         this.paintCross.setColor(this.red);
082: 
083:         // connect event handler
084:         this.setOnTouchListener(this);
085: 
086:         // do initialization stuff just once
087:         this.firstOnDraw = true;
088:     }
089: 
090:     // public interface
091:     public void setTicTacToeModel(ITicTacToe model) {
092: 
093:         this.model = model;
094:         this.model.setOnBoardChangedListener(this);
095:     }
096: 
097:     @Override
098:     protected void onDraw(Canvas canvas) {
099:         super.onDraw(canvas);
100: 
101:         if (this.isInEditMode()) {
102:             return; // do nothing in edit mode
103:         }
104: 
105:         if (this.firstOnDraw) {
106: 
107:             this.firstOnDraw = false;
108:             this.init();
109:         }
110: 
111:         this.drawBoard(canvas);
112:         this.drawStones(canvas);
113:     }
114: 
115:     // implementation of interface 'OnBoardChangedListener'
116:     @Override
117:     public void boardChanged() {
118: 
119:         // update view
120:         this.invalidate();
121:     }
122: 
123:     @Override
124:     public boolean onTouch(View view, MotionEvent event) {
125: 
126:         if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
127:             this.handleClickEvent((int) event.getX(), (int) event.getY());
128:         }
129: 
130:         return true;  // remove event from the event pipeline
131:     }
132: 
133:     // private helper methods
134:     private void handleClickEvent(int x, int y) {
135: 
136:         for (int row = 0; row < 3; row++) {
137:             for (int col = 0; col < 3; col++) {
138:                 if (this.helperRectangles[row][col].contains(x, y)) {
139: 
140:                     this.model.setStone(row + 1, col + 1); // update model
141:                     return;
142:                 }
143:             }
144:         }
145:     }
146: 
147:     private void drawBoard(Canvas canvas) {
148: 
149:         canvas.drawColor(this.black);
150: 
151:         // compute padding
152:         int paddingHorizontal = this.mmInPxHorizontal * 2;   // 2mm
153:         int paddingVertical = this.mmInPxVertical * 2;   // 2mm
154: 
155:         // vertical lines
156:         this.drawLine(
157:                 canvas,
158:                 this.left + this.distance,
159:                 this.top + paddingVertical,
160:                 this.left + this.distance,
161:                 this.top + this.length - paddingVertical
162:         );
163: 
164:         this.drawLine(
165:                 canvas,
166:                 this.left + 2 * this.distance,
167:                 this.top + paddingVertical,
168:                 this.left + 2 * this.distance,
169:                 this.top + this.length - paddingVertical
170:         );
171: 
172:         // horizontal lines
173:         this.drawLine(
174:                 canvas,
175:                 this.left + paddingHorizontal,
176:                 this.top + this.distance,
177:                 this.left + this.length - paddingHorizontal,
178:                 this.top + this.distance
179:         );
180: 
181:         this.drawLine(
182:                 canvas,
183:                 this.left + paddingHorizontal,
184:                 this.top + 2 * this.distance,
185:                 this.left + this.length - paddingHorizontal,
186:                 this.top + 2 * this.distance
187:         );
188:     }
189: 
190:     private void drawStones(Canvas canvas) {
191: 
192:         for (int row = 0; row < Dimension; row++) {
193:             for (int col = 0; col < Dimension; col++) {
194:                 if (this.model.getStoneAt(row + 1, col + 1) == GameStone.X) {
195:                     this.paintCross(canvas, row, col);
196:                 } else if (this.model.getStoneAt(row + 1, col + 1) == GameStone.O) {
197:                     this.paintCircle(canvas, row, col);
198:                 }
199:             }
200:         }
201:     }
202: 
203:     private void drawLine(Canvas canvas, float startX, float startY, float stopX, float stopY) {
204: 
205:         canvas.drawLine(startX, startY, stopX, stopY, this.paintLine);
206:     }
207: 
208:     private void paintCircle(Canvas canvas, int row, int col) {
209: 
210:         int padding = this.mmInPxHorizontal * 3;
211:         Rect rect = this.helperRectangles[row][col];
212:         float radius = this.distance / 2 - padding;
213:         canvas.drawCircle(rect.centerX(), rect.centerY(), radius, this.paintCircle);
214:     }
215: 
216:     private void paintCross(Canvas canvas, int row, int col) {
217: 
218:         // compute padding
219:         int paddingHorizontal = this.mmInPxHorizontal * 3;   // 3mm
220:         int paddingVertical = this.mmInPxVertical * 3;   // 3mm
221: 
222:         Rect rect = this.helperRectangles[row][col];
223: 
224:         canvas.drawLine(
225:                 rect.left + paddingHorizontal,
226:                 rect.top + paddingVertical,
227:                 rect.right - paddingHorizontal,
228:                 rect.bottom - paddingVertical,
229:                 this.paintCross);
230: 
231:         canvas.drawLine(
232:                 rect.right - paddingHorizontal,
233:                 rect.top + paddingVertical,
234:                 rect.left + paddingHorizontal,
235:                 rect.bottom - paddingVertical,
236:                 this.paintCross);
237:     }
238: 
239:     private void init() {
240: 
241:         // calculate helper variables
242:         int widthPx = this.getWidth();
243:         int heightPx = this.getHeight();
244: 
245:         if (widthPx <= heightPx) {
246: 
247:             this.length = widthPx;
248:             this.top = (heightPx - this.length) / 2;
249:             this.left = 0;
250:         } else {
251: 
252:             this.length = heightPx;
253:             this.top = 0;
254:             this.left = (widthPx - this.length) / 2;
255:         }
256: 
257:         this.distance = length / 3;
258: 
259:         // need these rectangles for touch/click detection and to draw circles and crosses
260:         Rect r1 = new Rect(
261:                 this.left,
262:                 this.top,
263:                 this.left + this.distance,
264:                 this.top + this.distance);
265: 
266:         Rect r2 = new Rect(
267:                 this.left + this.distance,
268:                 this.top,
269:                 this.left + 2 * this.distance,
270:                 this.top + this.distance);
271: 
272:         Rect r3 = new Rect(
273:                 this.left + 2 * this.distance,
274:                 this.top,
275:                 this.left + this.length,
276:                 this.top + this.distance);
277: 
278:         Rect r4 = new Rect(
279:                 this.left,
280:                 this.top + this.distance,
281:                 this.left + this.distance,
282:                 this.top + 2 * this.distance);
283: 
284:         Rect r5 = new Rect(
285:                 this.left + this.distance,
286:                 this.top + this.distance,
287:                 this.left + 2 * this.distance,
288:                 this.top + 2 * this.distance);
289: 
290:         Rect r6 = new Rect(
291:                 this.left + 2 * this.distance,
292:                 this.top + this.distance,
293:                 this.left + this.length,
294:                 this.top + 2 * this.distance);
295: 
296:         Rect r7 = new Rect(
297:                 this.left,
298:                 this.top + 2 * this.distance,
299:                 this.left + this.distance,
300:                 this.top + this.length);
301: 
302:         Rect r8 = new Rect(
303:                 this.left + this.distance,
304:                 this.top + 2 * this.distance,
305:                 this.left + 2 * this.distance,
306:                 this.top + this.length);
307: 
308:         Rect r9 = new Rect(
309:                 this.left + 2 * this.distance,
310:                 this.top + 2 * this.distance,
311:                 this.left + this.length,
312:                 this.top + this.length);
313: 
314:         this.helperRectangles = new Rect[][]{
315:                 {r1, r2, r3},
316:                 {r4, r5, r6},
317:                 {r7, r8, r9}
318:         };
319:     }
320: 
321:     private int pxToMMHorizontal(DisplayMetrics metrics) {
322: 
323:         // compute exact physical pixels per inch of the screen (X dimension)
324:         float xdpi = metrics.xdpi;
325:         return (int) Math.round(xdpi / 25.4);
326:     }
327: 
328:     private int pxToMMVertical(DisplayMetrics metrics) {
329: 
330:         // compute exact physical pixels per inch of the screen (Y dimension)
331:         float ydpi = metrics.ydpi;
332:         return (int) Math.round(ydpi / 25.4);
333:     }
334: }

Beispiel 7. Ansicht für Spielbrett.