mirror of
https://github.com/TwoFX/Morris.git
synced 2026-03-22 01:50:39 +00:00
396 lines
13 KiB
C#
396 lines
13 KiB
C#
/*
|
|
* GameWindow.xaml.cs
|
|
* Copyright (c) 2016 Markus Himmel
|
|
* This file is distributed under the terms of the MIT license
|
|
*/
|
|
|
|
using System;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Input;
|
|
using System.Windows.Media;
|
|
using System.Windows.Shapes;
|
|
|
|
namespace Morris
|
|
{
|
|
/// <summary>
|
|
/// Eine WPF-gestütze Mühle-GUI
|
|
/// </summary>
|
|
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 = 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 = 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
|
|
for (int i = 0; i < GameState.FIELD_SIZE; i++)
|
|
{
|
|
var pointI = CoordinateTranslator.CoordinatesFromID(i);
|
|
foreach (int j in GameState.GetConnected(i).Where(j => j < i))
|
|
{
|
|
var pointJ = CoordinateTranslator.CoordinatesFromID(j);
|
|
|
|
// "Fire and Forget": Sobald wir die Objekte zum Grid hinzugefügt haben,
|
|
// brauchen wir keine Referenzen mehr zu speichern.
|
|
var h = new Rectangle();
|
|
h.Fill = primaryColor;
|
|
h.Width = BLOCK_SIZE * Math.Abs(pointI[1] - pointJ[1]) + LINE_THICKNESS;
|
|
h.Height = BLOCK_SIZE * Math.Abs(pointI[0] - pointJ[0]) + LINE_THICKNESS;
|
|
h.HorizontalAlignment = HorizontalAlignment.Left;
|
|
h.VerticalAlignment = VerticalAlignment.Top;
|
|
|
|
h.Margin = new Thickness(
|
|
BLOCK_SIZE * Math.Min(pointI[1], pointJ[1]) + BLOCK_SIZE / 2 - LINE_THICKNESS / 2 + OFFSET_LEFT,
|
|
BLOCK_SIZE * Math.Min(pointI[0], pointJ[0]) + BLOCK_SIZE / 2 - LINE_THICKNESS / 2 + OFFSET_TOP,
|
|
0, 0);
|
|
|
|
grid.Children.Add(h);
|
|
}
|
|
}
|
|
|
|
// Beschriftung links
|
|
for (int i = 0; i < 7; i++)
|
|
{
|
|
Label l = new Label();
|
|
l.VerticalContentAlignment = VerticalAlignment.Center;
|
|
l.HorizontalAlignment = HorizontalAlignment.Left;
|
|
l.VerticalAlignment = VerticalAlignment.Top;
|
|
l.Content = (7 - i).ToString();
|
|
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 = LEGEND_SIZE;
|
|
l.Foreground = primaryColor;
|
|
grid.Children.Add(l);
|
|
}
|
|
|
|
// Beschriftung unten
|
|
for (int i = 0; i < 7; i++)
|
|
{
|
|
Label l = new Label();
|
|
l.HorizontalContentAlignment = HorizontalAlignment.Center;
|
|
l.HorizontalAlignment = HorizontalAlignment.Left;
|
|
l.VerticalAlignment = VerticalAlignment.Top;
|
|
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 = LEGEND_SIZE;
|
|
l.Foreground = primaryColor;
|
|
grid.Children.Add(l);
|
|
}
|
|
|
|
// Fenstergröße
|
|
Height = OFFSET_TOP + 7 * BLOCK_SIZE + OFFSET_BOTTOM;
|
|
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);
|
|
// 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.MouseDown += ellipseMouseDown;
|
|
e.MouseMove += ellipseMouseMove;
|
|
e.MouseUp += ellipseMouseUp;
|
|
resetPositon(i);
|
|
grid.Children.Add(e);
|
|
}
|
|
|
|
// Statusanzeige
|
|
status = new Label();
|
|
status.HorizontalContentAlignment = HorizontalAlignment.Center;
|
|
status.HorizontalAlignment = HorizontalAlignment.Left;
|
|
status.VerticalAlignment = VerticalAlignment.Top;
|
|
status.Width = 7 * BLOCK_SIZE + LINE_THICKNESS;
|
|
status.Margin = new Thickness(OFFSET_LEFT, STATUS_OFFSET_TOP, 0, 0);
|
|
status.FontSize = STATUS_SIZE;
|
|
grid.Children.Add(status);
|
|
}
|
|
|
|
public void Notify(IReadOnlyGameState state)
|
|
{
|
|
Dispatcher.Invoke(() =>
|
|
{
|
|
// Ellipsen einfärben
|
|
for (int i = 0; i < GameState.FIELD_SIZE; i++)
|
|
{
|
|
switch (state.Board[i])
|
|
{
|
|
case Occupation.Free:
|
|
pieces[i].Fill = Brushes.Transparent;
|
|
break;
|
|
|
|
case Occupation.Black:
|
|
pieces[i].Fill = Brushes.Black;
|
|
break;
|
|
|
|
case Occupation.White:
|
|
pieces[i].Fill = Brushes.White;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Statusanzeige, falls das Spiel vorbei ist
|
|
switch (state.Result)
|
|
{
|
|
case GameResult.BlackVictory:
|
|
status.Content = "Schwarz hat gewonnen";
|
|
break;
|
|
|
|
case GameResult.WhiteVictory:
|
|
status.Content = "Weiß hat gewonnen";
|
|
break;
|
|
|
|
case GameResult.Draw:
|
|
status.Content = "Unentschieden";
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|