From 4054f60952cde1466a983e7e83167d8ae94895e8 Mon Sep 17 00:00:00 2001 From: Markus Himmel Date: Sat, 27 Aug 2016 23:52:43 +0200 Subject: [PATCH] GUI is now fully functional. Very awesome --- Morris/CoordinateTranslator.cs | 6 +- Morris/GameState.cs | 5 +- Morris/GameWindow.xaml.cs | 270 +++++++++++++++++++++++++++++---- Morris/Program.cs | 4 +- 4 files changed, 248 insertions(+), 37 deletions(-) diff --git a/Morris/CoordinateTranslator.cs b/Morris/CoordinateTranslator.cs index 461ef87..de433cb 100644 --- a/Morris/CoordinateTranslator.cs +++ b/Morris/CoordinateTranslator.cs @@ -55,9 +55,9 @@ namespace Morris return new[] { 6 - (human[1] - '1'), human[0] - 'a' }; } - public static string HumanReadableFromCoordinates(Tuple coord) + public static string HumanReadableFromCoordinates(int[] coord) { - string res = new string(new [] { 'a' + coord.Item1, '1' + (char)coord.Item2 }.Cast().ToArray()); + string res = new string(new[] { 'a' + coord[1], '1' + coord[0] }.Select(x => (char)x).ToArray()); if (humans.Keys.Contains(res)) return res; @@ -70,7 +70,7 @@ namespace Morris return CoordinatesFromHumanReadable(HumanReadableFromID(id)); } - public static int IDFromCoordinates(Tuple coord) + public static int IDFromCoordinates(int[] coord) { return IDFromHumanReadable(HumanReadableFromCoordinates(coord)); } diff --git a/Morris/GameState.cs b/Morris/GameState.cs index f6a0fca..0e0fa5f 100644 --- a/Morris/GameState.cs +++ b/Morris/GameState.cs @@ -218,6 +218,9 @@ namespace Morris // bedingte Anweisung gepackt, auch wenn dies kompakter und eventuell marginal // schneller wäre + if (move == null) + return MoveValidity.Invalid; + // 1.: Ziel verifizieren if (move.To < 0 || move.To >= FIELD_SIZE) return MoveValidity.Invalid; // OOB @@ -315,7 +318,7 @@ namespace Morris Board[move.To] = (Occupation)NextToMove; // Wiederholte Stellung - if (!playerPhase.Values.Contains(Phase.Placing) && history.Any(pastBoard => Board.SequenceEqual(pastBoard))) + if (!playerPhase.Values.All(phase => phase == Phase.Placing) && history.Any(pastBoard => Board.SequenceEqual(pastBoard))) Result = GameResult.Draw; // ggf. entfernter Stein diff --git a/Morris/GameWindow.xaml.cs b/Morris/GameWindow.xaml.cs index 15d9133..7db49f5 100644 --- a/Morris/GameWindow.xaml.cs +++ b/Morris/GameWindow.xaml.cs @@ -5,46 +5,43 @@ */ using System; -using System.Collections.Generic; using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Threading; using System.Windows; using System.Windows.Controls; -using System.Windows.Data; -using System.Windows.Documents; using System.Windows.Input; using System.Windows.Media; -using System.Windows.Media.Imaging; using System.Windows.Shapes; namespace Morris { - /// /// Eine WPF-gestütze Mühle-GUI /// - public partial class GameWindow : Window, IGameStateObserver + public partial class GameWindow : Window, IGameStateObserver, IMoveProvider { - + // Diese konstanten Steuern das Aussehen des Spielfelds. private const int BLOCK_SIZE = 100; // Sollte durch 2 teilbar sein private const int OFFSET_LEFT = 50; - private const int OFFSET_TOP = 70; + private const int OFFSET_TOP = 50; private const int OFFSET_BOTTOM = 90; private const int OFFSET_RIGHT = 10; private const int LINE_THICKNESS = 6; // Sollte durch 2 teilbar sein private const int PIECE_RADIUS = 30; private const int LEGEND_OFFSET = 30; private const int LABEL_BUFFER_SIZE = 40; + private const int LEGEND_SIZE = 20; + private const int STATUS_SIZE = 20; + private const int STATUS_OFFSET_TOP = 10; private Ellipse[] pieces; - - private SolidColorBrush primaryColor = new SolidColorBrush(Color.FromRgb(0, 0, 0)); - + private SolidColorBrush primaryColor = Brushes.Black; private Label status; public GameWindow() { + // Dieser Konstruktor ist nicht sonderlich spannend zu lesen, da er einfach einen Haufen WPF-Objekte initialisiert. + InitializeComponent(); // Spielfield zeichnen @@ -84,7 +81,7 @@ namespace Morris l.Height = LABEL_BUFFER_SIZE; l.Margin = new Thickness(OFFSET_LEFT - LEGEND_OFFSET, OFFSET_TOP + i * BLOCK_SIZE + BLOCK_SIZE / 2 - LABEL_BUFFER_SIZE / 2, 0, 0); - l.FontSize = 20; + l.FontSize = LEGEND_SIZE; l.Foreground = primaryColor; grid.Children.Add(l); } @@ -99,7 +96,7 @@ namespace Morris l.Content = (char)('a' + i); l.Width = LABEL_BUFFER_SIZE; l.Margin = new Thickness(OFFSET_LEFT + i * BLOCK_SIZE + BLOCK_SIZE / 2 - LABEL_BUFFER_SIZE / 2, OFFSET_TOP + 7 * BLOCK_SIZE, 0, 0); - l.FontSize = 20; + l.FontSize = LEGEND_SIZE; l.Foreground = primaryColor; grid.Children.Add(l); } @@ -109,27 +106,23 @@ namespace Morris Width = OFFSET_LEFT + 7 * BLOCK_SIZE + OFFSET_RIGHT; - // Es gibt nicht für jeden tatsächlichen Spielstein eine Ellipse, die sich bewegt. Stattdessen gibt es eine // Ellipse auf jedem der 24 Spielfeldpunkte, die je nach Belegung Schwarz, weiß oder transparent ist pieces = new Ellipse[GameState.FIELD_SIZE]; for (int i = 0; i < GameState.FIELD_SIZE; i++) { var point = CoordinateTranslator.CoordinatesFromID(i); - var e = new Ellipse(); - e.Fill = null; + // e ist hier lediglich dazu da, kürzer als pieces[i] zu sein. + var e = pieces[i] = new Ellipse(); + e.Fill = Brushes.Transparent; e.Width = e.Height = 2 * PIECE_RADIUS; e.HorizontalAlignment = HorizontalAlignment.Left; e.VerticalAlignment = VerticalAlignment.Top; - - e.Margin = new Thickness( - OFFSET_LEFT + BLOCK_SIZE * point[1] + BLOCK_SIZE / 2 - PIECE_RADIUS, - OFFSET_TOP + BLOCK_SIZE * point[0] + BLOCK_SIZE / 2 - PIECE_RADIUS, - 0, 0); - + e.MouseDown += ellipseMouseDown; + e.MouseMove += ellipseMouseMove; + e.MouseUp += ellipseMouseUp; + resetPositon(i); grid.Children.Add(e); - - pieces[i] = e; } // Statusanzeige @@ -138,8 +131,8 @@ namespace Morris status.HorizontalAlignment = HorizontalAlignment.Left; status.VerticalAlignment = VerticalAlignment.Top; status.Width = 7 * BLOCK_SIZE + LINE_THICKNESS; - status.Margin = new Thickness(OFFSET_LEFT, 10, 0, 0); - status.FontSize = 40; + status.Margin = new Thickness(OFFSET_LEFT, STATUS_OFFSET_TOP, 0, 0); + status.FontSize = STATUS_SIZE; grid.Children.Add(status); } @@ -153,15 +146,15 @@ namespace Morris switch (state.Board[i]) { case Occupation.Free: - pieces[i].Fill = null; + pieces[i].Fill = Brushes.Transparent; break; case Occupation.Black: - pieces[i].Fill = new SolidColorBrush(Colors.Black); + pieces[i].Fill = Brushes.Black; break; case Occupation.White: - pieces[i].Fill = new SolidColorBrush(Colors.White); + pieces[i].Fill = Brushes.White; break; } } @@ -183,5 +176,220 @@ namespace Morris } }); } + + private void resetPositon(int index) + { + var point = CoordinateTranslator.CoordinatesFromID(index); + pieces[index].Margin = new Thickness( + OFFSET_LEFT + BLOCK_SIZE * point[1] + BLOCK_SIZE / 2 - PIECE_RADIUS, + OFFSET_TOP + BLOCK_SIZE * point[0] + BLOCK_SIZE / 2 - PIECE_RADIUS, + 0, 0); + } + + + // Überblick über die Benutzerinteraktion in der GUI: + + // Wenn ein Zug angefordert wird, wird die Methode GetNextMove vom Spielthread + // aus aufgerufen. Diese bestimmt, ob je nach Spielzustand ein Klick oder ein + // Drag/Drop erforderlich ist und setzt mode entsprechend. Außerdem wird, basierend + // auf dem Spielfeld in validSources abgespeichert, welche Felder angeklickt bzw. gezogen + // werden dürfen. Dieser Thread muss + // nun blockieren, bis ein Zug durch den Benutzer asusgeführt wurde. Dazu wartet + // er auf das AutoResetEvent sync. + // Wenn ein erlaubtes Feld angeklickt wurde, wird die ellipseMouseDown-Methode aufgerufen + // (Event Handler). Wenn lediglich ein Klick notwendig war, speichert die Methode die ID + // des Felds in der Instanzvariable source und löst das AutoResetEvent aus. + // Falls ein Drag/Drop stattfinden soll, wird die Modusvariable entsprechend modifiziert, + // die Maus eingefangen und die relative Position des Mauszeigers zum Spielstein im Feld + // Offset gespeichert. Immer wenn die Maus bewegt wird, wird der Spielstein nun so bewegt, + // dass die relative Position zur Maus gleich bleibt. Wird die Maus losgelassen, wird der + // Mauszeiger freigelassen und das Feld, auf dem der Spielstein fallengelassen wurde, berechnet. + // Dieses wird dann in der Instanz gespeichert und sync ausgelöst. + + // Aus den gewonnen Daten wird dann wieder im Spielthread ein GameMove gebaut und geprüft, ob + // dieser einen gegnerischen Stein schlägt. Wenn ja, wird ein weiterer Klick eingeholt. + // *Nachdem* dies stattgefunden hat wird dann gegebenenfalls ein bewegter Spielstein zurück + // auf seine Ursprungsposition gebracht (wie bereits erwähnt werden die Ellipsen nicht wirklich + // bewegt, sondern die unsichtbare Ellipse am Zielort wird beim nächsten Aufruf von Notify + // entsprechend eingefärbt). + + private enum Mode + { + Normal, + ExpectingClick, + ExpectingDrag, + Dragging + } + + private Mode mode = Mode.Normal; + private Point offset;// Relative Position des bewegten Spielsteins zum Mauszeiger + private bool error = false; // Ob der Stein auf ein nonexistentes Feld gezogen wurde + private bool[] validSources = new bool[GameState.FIELD_SIZE]; // Felder, die angeklickt/gezogen werden dürfen + private int source = -1; // Welcher Stein angeklickt/gezogen wurde + private int destination = -1; // Wo der Stein hingezogen wurde + AutoResetEvent sync = new AutoResetEvent(false); // Damit der Spielthread weiß, dass auf dem UI-Thread ein Zug gemacht wurde + + private void ellipseMouseDown(object sender, MouseEventArgs e) + { + Ellipse ellipse = sender as Ellipse; + int index = Array.IndexOf(pieces, ellipse); + + if (index == -1) + return; + + switch (mode) + { + case Mode.ExpectingClick: + if (!validSources[index]) + break; + + source = index; + sync.Set(); // Zurück zum Spielthread + break; + + case Mode.ExpectingDrag: + if (!validSources[index]) + break; + + source = index; + mode = Mode.Dragging; + offset = e.GetPosition(ellipse); + Mouse.Capture(ellipse); + break; + } + } + + private void ellipseMouseMove(object sender, MouseEventArgs e) + { + Ellipse ellipse = sender as Ellipse; + + if (mode != Mode.Dragging || e.LeftButton != MouseButtonState.Pressed) + return; + + // Den Spielstein, der gerade gezogen wird, auf die richtige Position bewegen + Point pos = e.GetPosition(grid); + ellipse.Margin = new Thickness(pos.X - offset.X, pos.Y - offset.Y, 0, 0); + } + + private void ellipseMouseUp(object sender, MouseEventArgs e) + { + Ellipse ellipse = sender as Ellipse; + + if (mode != Mode.Dragging) + return; + + // Das Spielfeld wird hier imaginär in ein Raster der Größe BLOCK_SIZE * BLOCK_SIZE aufgeteilt. + // Im Zentrum jedes Blocks liegt ein Feld + int left = (int)Math.Floor((ellipse.Margin.Left + PIECE_RADIUS - OFFSET_LEFT) / BLOCK_SIZE); + // 6 - x, weil WPF von oben, Mühle aber von unten zählt + int top = 6 - (int)Math.Floor((ellipse.Margin.Top + PIECE_RADIUS - OFFSET_TOP) / BLOCK_SIZE); + + try + { + destination = CoordinateTranslator.IDFromCoordinates(new[] { top, left }); + } + catch (ArgumentException) + { + // IDFromCoordinates schmeißt hier, wenn der Spielstein auf ein Stelle im Spielfeld, die kein + // Feld enthält oder aus dem Feld heraus gezogen wurde + error = true; + } + + // Maus freigeben + Mouse.Capture(null); + sync.Set(); // Zurück zum Spielthread + } + + public GameMove GetNextMove(IReadOnlyGameState state) + { + // Status + string phase; + switch (state.GetPhase(state.NextToMove)) + { + case Phase.Placing: + phase = "platziert"; + break; + case Phase.Moving: + phase = "bewegt"; + break; + default: + phase = "fliegt"; + break; + } + Dispatcher.Invoke(() => status.Content = $"{(state.NextToMove == Player.Black ? "Schwarz" : "Weiß")} {phase}."); + + GameMove move = null; + + int oldsource = -1; // Enthält ggf. den Spielstein, der bewegt wurde + + if (state.GetPhase(state.NextToMove) == Phase.Placing) + { + // Wir brauchen nur einen Klick in ein freies Feld + for (int i = 0; i < GameState.FIELD_SIZE; i++) + { + validSources[i] = state.Board[i] == Occupation.Free; + } + mode = Mode.ExpectingClick; + + sync.WaitOne(); // Warten... + + // source enthält jetzt den angeklickten Punkt + mode = Mode.Normal; + move = GameMove.Place(source); + } + else + { + // Wir brauchen Drag&Drop von einem vom aktuellen Spieler besetzten Feld + for (int i = 0; i < GameState.FIELD_SIZE; i++) + { + validSources[i] = state.Board[i] == (Occupation)state.NextToMove; + } + mode = Mode.ExpectingDrag; + error = false; + + sync.WaitOne(); // Warten + + // Ungültiges Feld, einfach einen ungültigen Zug zurückgeben, damit GetNextMove + // erneut aufgerufen wird + if (error) + return null; + + // Source und Destination erhalten Anfang und Ende des Drag-Events + mode = Mode.Normal; + move = GameMove.Move(source, destination); + oldsource = source; + } + + if (state.IsValidMove(move) == MoveValidity.ClosesMill) + { + // Selbes Spiel wie oben. + Dispatcher.Invoke(() => status.Content = "Bitte gegnerischen Stein zum Entfernen wählen."); + for (int i = 0; i < GameState.FIELD_SIZE; i++) + { + validSources[i] = state.Board[i] == (Occupation)state.NextToMove.Opponent(); + } + mode = Mode.ExpectingClick; + sync.WaitOne(); + mode = Mode.Normal; + move = move.WithRemove(source); + } + + // Erst jetzt setzen wir den Stein zurück. Grund dafür ist, dass + // so der Stein nicht hin- und herspringt. Wenn wir ihn direkt zurücksetzen würden, + // kann es passieren, dass der Stein zurückgestzt wird und dann aber noch ein zu + // entfernender Stein abgefragt wird. Erst nachdem dieser abgefragt wurde, wird dann + // der Zielstein richtig eingefärbt. Wenn man aber den Stein erst ganz am Ende zurücksetzt, + // gibt es keine derarten Sprünge. + if (oldsource >= 0) + Dispatcher.Invoke(() => { + // Kleiner Hack, es sieht schöner aus, wenn der Stein schon hier ausgeblendet wird. Wir wissen, dass das der + // Fall sein wird, solange der Zug gültig ist, deshalb prüfen wir das hier schonmal. + if (state.IsValidMove(move) == MoveValidity.Valid) pieces[oldsource].Fill = Brushes.Transparent; + resetPositon(oldsource); + }); + + Dispatcher.Invoke(() => status.Content = null); + return move; + } } } diff --git a/Morris/Program.cs b/Morris/Program.cs index a18b185..5168cbc 100644 --- a/Morris/Program.cs +++ b/Morris/Program.cs @@ -15,10 +15,10 @@ namespace Morris var a = new ConsoleInteraction(); var b = new RandomBot(); var w = new GameWindow(); - var g = new Game(b, b); + var g = new Game(w, w); g.AddObserver(a); g.AddObserver(w); - Task.Run(() => g.Run()); + Task.Run(() => g.Run(0)); new Application().Run(w); } }