GUI is now fully functional. Very awesome

This commit is contained in:
Markus Himmel
2016-08-27 23:52:43 +02:00
parent a64674f1a3
commit 4054f60952
4 changed files with 248 additions and 37 deletions

View File

@@ -55,9 +55,9 @@ namespace Morris
return new[] { 6 - (human[1] - '1'), human[0] - 'a' };
}
public static string HumanReadableFromCoordinates(Tuple<int, int> coord)
public static string HumanReadableFromCoordinates(int[] coord)
{
string res = new string(new [] { 'a' + coord.Item1, '1' + (char)coord.Item2 }.Cast<char>().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<int, int> coord)
public static int IDFromCoordinates(int[] coord)
{
return IDFromHumanReadable(HumanReadableFromCoordinates(coord));
}

View File

@@ -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

View File

@@ -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
{
/// <summary>
/// Eine WPF-gestütze Mühle-GUI
/// </summary>
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;
}
}
}

View File

@@ -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);
}
}