Vier Gewinnt mit Xamarin.Forms und SkiaSharp

1. Aufgabe

Dem Spieleklassiker „Vier Gewinnt“ (engl. Connect Four oder auch Captain's Mistress) haben wir bereits im Abschnitt Windows Presentation Foundation unsere Aufmerksamkeit gewidmet. Ziel ist es, auf einem senkrecht stehenden Spielbrett als Erster vier eigene Spielsteine in eine waagerechte, senkrechte oder diagonale Linie zu bringen. Um der Sektion Xamarin wieder etwas mehr Gewicht zu verleihen, wollen wir in dieser Aufgabe für dieses Spiel eine Xamarin.Forms-App realisieren und dabei – soweit machbar – den vorhandenen WPF-Quellcodestapel nach Xamarin.Forms portieren.

Für die graphischen Ausgaben sollte SkiaSharp, eine plattformübergreifende 2D-Graphikbibliothek, zum Einsatz kommen. Auf einem Samsung Galaxy S6 Device sieht meine Portierung so aus (Abbildung 1):

Vier-Gewinnt als Xamarin.Forms App.

Abbildung 1. Vier-Gewinnt als Xamarin.Forms App.

2. Lösung

Quellcode: Siehe auch github.com/peterloos/Xamarin_ConnectFour.git. Beim Anlegen eines Xamarin.Forms-Projekts achten Sie bitte darauf, dass im nachfolgenden Dialog (Abbildung 2) die Einstellungen „Blank App“ und „Portable Class Library (PCL)“ angekreuzt sind:

Einstellungen einer Xamarin.Forms-App in Visual Studio.

Abbildung 2. Einstellungen einer Xamarin.Forms-App in Visual Studio.


Nun legen wir einige Grundlagen zum Einsatz von SkiaSharp. Mit dem NuGet-Paketmanager von Visual Studio (Abbildung 3) ist nach dem Anlegen des Projekts die SkiaSharp.Views.Forms-Bibliothek im gesamten Xamarin.Forms-Projekt zu installieren:

NuGet-Paketmanager – hier am Beispiel der SkiaSharp.Views.Forms-Bibliothek.

Abbildung 3. NuGet-Paketmanager – hier am Beispiel der SkiaSharp.Views.Forms-Bibliothek.


Damit sind wir schon beim Quellcode angekommen. Um im XAML Elemente der SkiaSharp-Klassenbibliothek benutzen zu können, benötigen wir eine entsprechende Namensraum-Direktive:

xmlns:skia="clr-namespace:SkiaSharp.Views.Forms;assembly=SkiaSharp.Views.Forms"

Den Namensraum-Bezeichner (im oberen Beispiel: skia) dürfen Sie dabei frei wählen; er findet Verwendung in den XAML-Anweisungen, wenn Sie auf SkiaSharp-Steuerelemente zugreifen wollen, zum Beispiel

<skia:SKCanvasView
    HorizontalOptions="FillAndExpand"
    VerticalOptions="FillAndExpand" 
    ... />

Um die (statische) Oberfläche der App zu gestalten, kommen wir um eine Neu-Implementierung nicht umhin (Listing 1). Die Unterschiede zur vorhandenen WPF-Realisierung sind doch größerer Natur:

01: <?xml version="1.0" encoding="utf-8" ?>
02: 
03: <ContentPage
04:     xmlns="http://xamarin.com/schemas/2014/forms"
05:     xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
06:     xmlns:local="clr-namespace:ConnectFour"
07:     xmlns:skia="clr-namespace:SkiaSharp.Views.Forms;assembly=SkiaSharp.Views.Forms"
08:     x:Class="ConnectFour.MainPage">
09: 
10:     <ContentPage.Content>
11: 
12:         <StackLayout
13:             HorizontalOptions="FillAndExpand"
14:             VerticalOptions="FillAndExpand"
15:             Orientation="Vertical" 
16:             Margin="10">
17: 
18:             <Label
19:                 HorizontalOptions="FillAndExpand"
20:                 VerticalOptions="Fill"
21:                 HorizontalTextAlignment="Start" 
22:                 VerticalTextAlignment="Center"
23:                 FontSize="32"
24:                 Text="Another Connect Four" />
25: 
26:             <Label       
27:                 x:Name="LabelStatus"
28:                 HorizontalOptions="FillAndExpand"
29:                 VerticalOptions="Fill" 
30:                 HorizontalTextAlignment="Start" 
31:                 VerticalTextAlignment="Center"
32:                 FontSize="20"
33:                 Text="" />
34: 
35:             <skia:SKCanvasView
36:                 x:Name="CanvasViewConnectFour"
37:                 HorizontalOptions="FillAndExpand"
38:                 VerticalOptions="FillAndExpand"
39:                 PaintSurface="PaintAppSurface"
40:                 EnableTouchEvents="True"  
41:                 Touch="CanvasBoardTouched"
42:                 BackgroundColor="LightGray" />
43: 
44:             <Button
45:                 x:Name="ButtonClear"
46:                 HorizontalOptions="FillAndExpand"
47:                 VerticalOptions="Fill" 
48:                 Clicked="ButtonClicked"
49:                 Text="Reset" />
50: 
51:         </StackLayout>
52: 
53:     </ContentPage.Content>
54: 
55: </ContentPage>

Beispiel 1. Klasse MainPage – Gestaltung der App-Oberfläche in XAML.


Im Code-Behind-Teil lassen sich etliche Code-Passagen, die sich ausschließlich auf die Spiele-Logik beziehen, nahezu Eins zu Eins wiederverwenden (Listing 2):

001: using SkiaSharp;
002: using SkiaSharp.Views.Forms;
003: using System;
004: using Xamarin.Forms;
005: 
006: namespace ConnectFour
007: {
008:     enum Stone { Empty, Black, White };
009: 
010:     enum GameStatus
011:     {
012:         WhiteIsNext,
013:         BlackIsNext,
014:         WhiteHasWon,
015:         BlackHasWon,
016:         EvenScore
017:     }
018: 
019:     struct BoardLocation
020:     {
021:         public int Row { get; set; }
022:         public int Col { get; set; }
023:     }
024: 
025:     public partial class MainPage : ContentPage
026:     {
027:         private const int MaxRows = 6;
028:         private const int MaxCols = 7;
029: 
030:         private const int BallMargin = 20;
031:         private const int BallStrokeWidth = 20;
032: 
033:         // game logic
034:         private GameStatus status;
035:         private Stone[,] board;
036:         private int moves;
037: 
038:         // drawing utils
039:         private Label labelStatus;
040:         private Button buttonClear;
041:         private SKCanvasView canvasConnectFour;
042:         private SKPaint singlePaint;
043:         private SKPoint[,] centerCoordinates;
044:         private int surfaceWidth;
045:         private int surfaceHeight;
046:         private int radius;
047: 
048:         public MainPage()
049:         {
050:             InitializeComponent();
051:             this.InitGame();
052:             this.ResetGame();
053:         }
054: 
055:         private void InitGame()
056:         {
057:             // retrieve UI objects
058:             this.buttonClear = this.FindByName<Button>("ButtonClear");
059:             this.labelStatus = this.FindByName<Label>("LabelStatus");
060:             this.canvasConnectFour = this.FindByName<SKCanvasView>("CanvasViewConnectFour");
061: 
062:             // must be initialized upon first 'PaintSurface' event
063:             this.radius = -1;
064: 
065:             // create objects
066:             this.board = new Stone[MaxRows, MaxCols];
067:             this.singlePaint = new SKPaint();
068:             this.centerCoordinates = new SKPoint[MaxRows, MaxCols];
069:         }
070: 
071:         private void ResetGame()
072:         {
073:             this.status = GameStatus.WhiteIsNext;
074:             this.labelStatus.Text = "1. Player starts ...";
075:             this.labelStatus.BackgroundColor = Color.White;
076:             this.moves = 0;
077:             this.ResetBoard();
078:         }
079: 
080:         // event handling methods
081:         private void ButtonClicked(Object sender, EventArgs e)
082:         {
083:             this.ResetGame();
084:             this.canvasConnectFour.InvalidateSurface();
085:         }
086: 
087:         private void PaintAppSurface(Object sender, SKPaintSurfaceEventArgs args)
088:         {
089:             if (this.radius == -1)
090:             {
091:                 this.CalculateShapeMeasurements(args);
092:             }
093: 
094:             using (SKCanvas canvas = args.Surface.Canvas)
095:             {
096:                 this.DrawBoard(canvas);
097:             }
098:         }
099: 
100:         // private board helper methods
101:         private void CalculateShapeMeasurements(SKPaintSurfaceEventArgs args)
102:         {
103:             this.surfaceWidth = args.Info.Width;
104:             this.surfaceHeight = args.Info.Height;
105: 
106:             float rawRadius = this.surfaceWidth / (2 * MaxCols);
107:             this.radius = (int)(rawRadius - BallMargin);
108: 
109:             float distanceFromTop = (this.surfaceHeight - (MaxRows * 2 * rawRadius)) / 2;
110: 
111:             for (int i = 0; i < MaxRows; i++)
112:             {
113:                 for (int j = 0; j < MaxCols; j++)
114:                 {
115:                     float centerX = (2 * j + 1) * rawRadius;
116:                     float centerY = distanceFromTop + (2 * i + 1) * rawRadius;
117: 
118:                     this.centerCoordinates[i, j] = new SKPoint(centerX, centerY);
119:                 }
120:             }
121:         }
122: 
123:         private void ResetBoard()
124:         {
125:             for (int i = 0; i < MaxRows; i++)
126:             {
127:                 for (int j = 0; j < MaxCols; j++)
128:                 {
129:                     this.board[i, j] = Stone.Empty;
130:                 }
131:             }
132:         }
133: 
134:         // private drawing helper methods (valid SKCanvas object needed)
135:         private void DrawBoard(SKCanvas canvas)
136:         {
137:             for (int i = 0; i < MaxRows; i++)
138:             {
139:                 for (int j = 0; j < MaxCols; j++)
140:                 {
141:                     Stone s = this.board[MaxRows - i - 1, j];
142: 
143:                     SKColor color =
144:                         (s == Stone.White) ? SKColors.Green :
145:                         (s == Stone.Black) ? SKColors.Red : SKColors.LightBlue;
146: 
147:                     this.DrawBall(canvas, i, j, this.singlePaint, color);
148:                 }
149:             }
150:         }
151: 
152:         private void DrawBall(SKCanvas canvas, int row, int col, SKPaint paint, SKColor color)
153:         {
154:             float centerX = this.centerCoordinates[row, col].X;
155:             float centerY = this.centerCoordinates[row, col].Y;
156: 
157:             // draw outline of circle
158:             paint.Style = SKPaintStyle.Stroke;
159:             paint.Color = SKColors.Black;
160:             paint.StrokeWidth = BallStrokeWidth;
161:             canvas.DrawCircle(centerX, centerY, this.radius, paint);
162: 
163:             // fill interior of circle
164:             paint.Style = SKPaintStyle.Fill;
165:             paint.Color = color;
166:             canvas.DrawCircle(centerX, centerY, this.radius, paint);
167:         }
168: 
169:         private void CanvasBoardTouched(Object sender, SKTouchEventArgs e)
170:         {
171:             BoardLocation cell = this.CalcBoardLocation(e.Location);
172: 
173:             if (!this.IsValidBoardRange(cell))
174:             {
175:                 DisplayAlert("Touch", "Wrong location !", "Continue");
176:                 return;
177:             }
178: 
179:             if (!this.IsValidBoardLocation(cell))
180:             {
181:                 DisplayAlert("Touch", "Wrong position !", "Continue");
182:                 return;
183:             }
184: 
185:             // update board
186:             this.board[cell.Row, cell.Col] =
187:                 (this.status == GameStatus.WhiteIsNext) ?
188:                 Stone.White : Stone.Black;
189: 
190:             // evaluate move
191:             this.moves++;
192:             if (this.moves == MaxRows * MaxCols)
193:             {
194:                 // handling even score
195:                 this.status = GameStatus.EvenScore;
196:             }
197:             else if (this.AreThereFourInARow(cell.Row, cell.Col, this.board[cell.Row, cell.Col]))
198:             {
199:                 // games has ended, set status accordingly
200:                 this.status = (this.status == GameStatus.WhiteIsNext) ?
201:                     GameStatus.WhiteHasWon : GameStatus.BlackHasWon;
202:             }
203:             else
204:             {
205:                 // game continues, set status accordingly
206:                 this.status = (this.status == GameStatus.WhiteIsNext) ?
207:                 GameStatus.BlackIsNext : GameStatus.WhiteIsNext;
208:             }
209: 
210:             this.UpdateUserInterface();
211: 
212:             this.canvasConnectFour.InvalidateSurface();
213:         }
214: 
215:         private BoardLocation CalcBoardLocation(SKPoint currentLocation)
216:         {
217:             float rawRadius = this.surfaceWidth / (2 * MaxCols);
218: 
219:             float distanceFromTop = (this.surfaceHeight - (MaxRows * 2 * rawRadius)) / 2;
220: 
221: 
222:             int col = (int)(currentLocation.X / (2 * rawRadius));
223:             int row = (int)((currentLocation.Y - distanceFromTop) / (2 * rawRadius));
224: 
225:             return new BoardLocation() { Row = MaxRows - row - 1, Col = col };
226:         }
227: 
228:         private bool IsValidBoardRange(BoardLocation cell)
229:         {
230:             return cell.Col >= 0 && cell.Col < MaxCols && cell.Row >= 0 && cell.Row < MaxRows;
231:         }
232: 
233:         private bool IsValidBoardLocation(BoardLocation cell)
234:         {
235:             // check values
236:             if (cell.Row < 0 || cell.Row >= MaxRows)
237:                 return false;
238:             if (cell.Col < 0 || cell.Col >= MaxCols)
239:                 return false;
240: 
241:             // is position occupied
242:             if (this.board[cell.Row, cell.Col] != Stone.Empty)
243:                 return false;
244: 
245:             // is position valid
246:             for (int i = 0; i < cell.Row; i++)
247:             {
248:                 if (this.board[i, cell.Col] == Stone.Empty)
249:                 {
250:                     return false;
251:                 }
252:             }
253: 
254:             // game is already over
255:             if (this.status == GameStatus.WhiteHasWon ||
256:                 this.status == GameStatus.BlackHasWon ||
257:                 this.status == GameStatus.EvenScore)
258:                 return false;
259: 
260:             // position is okay
261:             return true;
262:         }
263: 
264:         private void UpdateUserInterface()
265:         {
266:             switch (this.status)
267:             {
268:                 case GameStatus.WhiteIsNext:
269:                     this.labelStatus.Text = "1. Player is next ...";
270:                     break;
271: 
272:                 case GameStatus.BlackIsNext:
273:                     this.labelStatus.Text = "2. Player is next ...";
274:                     break;
275: 
276:                 case GameStatus.WhiteHasWon:
277:                     this.labelStatus.BackgroundColor = Color.Yellow;
278:                     this.labelStatus.Text = "Game over: 1. Player has won !";
279:                     break;
280: 
281:                 case GameStatus.BlackHasWon:
282:                     this.labelStatus.BackgroundColor = Color.Yellow;
283:                     this.labelStatus.Text = "Game over: 2. Player has won !";
284:                     break;
285: 
286:                 case GameStatus.EvenScore:
287:                     this.labelStatus.BackgroundColor = Color.LightBlue;
288:                     this.labelStatus.Text = "Game ended in a draw !";
289:                     break;
290:             }
291:         }
292: 
293:         private bool AreThereFourInARow(int row, int col, Stone lastStone)
294:         {
295:             // handling vertical diagonal
296:             int count = 0;
297:             for (int i = 0; i < MaxRows; i++)
298:             {
299:                 if (this.board[i, col] == lastStone)
300:                 {
301:                     count++;
302:                     if (count == 4)
303:                         return true;
304:                 }
305:                 else
306:                     count = 0;
307:             }
308: 
309:             // handling horizontal diagonal
310:             count = 0;
311:             for (int j = 0; j < MaxCols; j++)
312:             {
313:                 if (this.board[row, j] == lastStone)
314:                 {
315:                     count++;
316:                     if (count == 4)
317:                         return true;
318:                 }
319:                 else
320:                     count = 0;
321:             }
322: 
323:             // handling first diagonal ([left,bottom] -> [right,top])
324:             int delta1 = Math.Min(row, col);
325:             int delta2 = Math.Min(MaxRows - row, MaxCols - col);
326: 
327:             int row1 = row - delta1;
328:             int col1 = col - delta1;
329:             int row2 = row + delta2;
330:             int col2 = col + delta2;
331: 
332:             count = 0;
333:             for (int i = row1, j = col1; i < row2 && j < col2; i++, j++)
334:             {
335:                 if (this.board[i, j] == lastStone)
336:                 {
337:                     count++;
338:                     if (count == 4)
339:                         return true;
340:                 }
341:                 else
342:                     count = 0;
343:             }
344: 
345:             // handling second diagonal ([left,top] -> [right,bottom])
346:             delta1 = Math.Min(MaxRows - row - 1, col);
347:             delta2 = Math.Min(row, MaxCols - col - 1);
348: 
349:             row1 = row + delta1;
350:             col1 = col - delta1;
351:             row2 = row - delta2;
352:             col2 = col + delta2;
353: 
354:             count = 0;
355:             for (int i = row1, j = col1; i >= row2 && j <= col2; i--, j++)
356:             {
357:                 if (this.board[i, j] == lastStone)
358:                 {
359:                     count++;
360:                     if (count == 4)
361:                         return true;
362:                 }
363:                 else
364:                     count = 0;
365:             }
366: 
367:             return false;
368:         }
369:     }
370: }

Beispiel 2. Klasse MainPage – Spiellogik - Codebehind in C#.


Wir werfen zum Abschluss einen Blick auf die PaintAppSurface-Methode in Zeile 87 von Listing 2. Das Zeichnen der App-Oberfläche wird von SkiaSharp als Ereignis angesehen. Dementsprechend ist diese Methode mit dem SKCanvasView-Objekt und seinem Ereignis PaintSurface im Sinne einer ereignisgesteuerten Programmausführung verknüpft:

<skia:SKCanvasView
    x:Name="CanvasViewFourWins"
    HorizontalOptions="FillAndExpand"
    VerticalOptions="FillAndExpand"
    PaintSurface="PaintAppSurface"
    ... />

Dieses PaintSurface-Ereignis wird beispielsweise unmittelbar nach dem App-Start ausgelöst, um auf diese Weise die App-Oberfläche erstmalig zu zeichnen. Bleibt zu klären, wie wir programmgesteuert diese Methode zur Ausführung bringen können, wenn sich die Oberfläche des Spielfelds während der Laufzeit ändert? Für diesen Zweck besitzt das SKCanvasView-Objekt eine Methode InvalidateSurface. Ihr Aufruf zieht unmittelbar ein Auslösen des PaintSurface-Ereignisses nach sich.

Noch ein letzter Hinweis zu dieser Betrachtung: In Zeile 87 wird aus dem SKPaintSurfaceEventArgs-Parameter eine Referenz eines SKCanvas-Objekts extrahiert, das die eigentlichen Methoden zum Zeichnen bereitstellt. Man könnte geneigt sein, diese Referenz im Instanzvariablenbereich der App abzuspeichern, um damit zu einem beliebigen Zeitpunkt graphische Ausgaben zu tätigen. Vorsicht, diese Vorgehensweise führt zum Absturz der App! Das SKCanvas-Objekt ist ausschließlich im Kontext eines PaintSurface-Ereignisses gültig – also im Kontext der Methode, die auf das PaintSurface-Ereignis reagiert. Für andere SkiaSharp-Objekte gilt diese Einschränkung nicht, wie beispielsweise für SKPaint-Objekte.