Introduction
In this article, I will show you how to draw the game of GO in WPF. Because you need a lot of shapes, for performance reasons, I use the System.Windows.Media.DrawingVisual
class for drawing instead of the System.Windows.Shapes
classes. Please be aware that this is my first article on The Code Project and my very first WPF project. So I think I made a lot of mistakes. Any suggestions on how to improve my code will be appreciated.
This article only shows the drawing methods. I will cover the board logic in a later article.
Background
I just started to learn WPF and I'm very excited about it! So to learn more about WPF, I thought it would be cool to draw the best game in the world in WPF. GO is about 4000 years old and is very well known especially in Asia. If you are interested, you can learn more about it at: Wikipedia Go game.
The GoBoardPainter
As I said before, I'm using the DrawingVisual
class here. This way you only need one FrameworkElement
which saves you a lot of overhead. The actual drawings are derived from the Visual
class which is much smaller. So let's start with our BoardPainter
like this:
public class GoBoardPainter : FrameworkElement
{
private List<Visual> m_Visuals = new List<Visual>();
private DrawingVisual m_BoardVisual, m_StonesVisual, m_StarPointVisual,
m_CoordinatesVisual, m_AnnotationVisual, m_MouseHoverVisual;
private Dictionary<GoBoardPoint, Stone> m_StoneList =
new Dictionary<GoBoardPoint, Stone>();
private ObservableCollection<GoBoardAnnotation> m_AnnotationsList =
new ObservableCollection<GoBoardAnnotation>();
... more private variables ...
public GoBoardPainter()
{
Resources.Source = new Uri(
"pack://application:,,,/GoBoard;component/GoBoardPainterResources.xaml");
m_BlackStoneBrush = (Brush)TryFindResource("blackStoneBrush");
... assign some resources...
InitializeBoard(this.BoardSize - 1);
}
protected override int VisualChildrenCount
{
get { return m_Visuals.Count; }
}
protected override Visual GetVisualChild(int index)
{
if (index < 0 || index >= m_Visuals.Count)
{
throw new ArgumentOutOfRangeException("index");
}
return m_Visuals[index];
}
}
As you can see, we have a List with Visuals. This list will be drawn to the FrameworkElement
. In this example, I only add one DrawingVisual
to the list which is the m_BoardVisual
. To also draw the Stones, Annotations, etc. I will add these DrawingVisuals to the m_BoardVisual
. The overridden methods are necessary, because we use our own visual drawing mechanics. Now let's look at the InitializeBoard()
method:
private void InitializeBoard(int boardSize)
{
m_Visuals.ForEach(delegate(Visual v) { RemoveVisualChild(v); });
m_Visuals.Clear();
m_StoneList.Clear();
m_AnnotationsList.Clear();
m_AnnotationsList.CollectionChanged += new NotifyCollectionChangedEventHandler(
m_AnnotationsList_CollectionChanged);
m_BoardSize = boardSize;
m_GoBoardRect = new Rect(new Size(m_BoardSize * m_BoardWidthFactor,
m_BoardSize * m_BoardHeightFactor));
m_GoBoardHitBox = m_GoBoardRect;
m_GoBoardHitBox.Inflate((m_BoardWidthFactor / 2), (m_BoardHeightFactor / 2));
this.Width = m_GoBoardRect.Width + m_Border * 2;
this.Height = m_GoBoardRect.Height + m_Border * 2;
DrawBoard();
DrawCoordinates();
DrawStarPoints();
DrawStones();
DrawMouseHoverVisual();
m_Visuals.Add(m_BoardVisual);
m_Visuals.ForEach(delegate(Visual v) { AddVisualChild(v); });
}
Because we are able to reinitialize the board any time, we have to first clear our Lists and remove all Visuals from the FrameworkElement
. After this, we create our Board Rectangle. A Go board has a 14 to 15 size ratio so the board length is (boardSize * 14)
and the board height is (boardSize * 15)
.
The Hit Box Rectangle is slightly bigger than the normal Goboard and our complete FrameworkElement
also gets a border where we can draw some coordinates.
Now we draw our Visuals in the appropriate order. Each Drawing method adds its DrawingVisual
to the m_BoardVisual
. After we add the Visual List to the FrameworkElement
, the initialization is complete.
Now we need two helper methods, which translate a Goboard coordinate to a DrawingVisual
coordinate. m_BoardWidthFactor
is 14
and m_BoardHeightFactor
is 15
.
private double getPosX(double value)
{
return m_BoardWidthFactor * value + m_Border;
}
private double getPosY(double value)
{
return m_BoardHeightFactor * value + m_Border;
}
Draw the Board and Stones
I will show you how to draw the board and the stones. The rest is pretty much the same. Let's look at the DrawBoard
method:
private void DrawBoard()
{
m_BoardVisual = new DrawingVisual();
using (DrawingContext dc = m_BoardVisual.RenderOpen())
{
dc.DrawRectangle(m_BoardBrush, new Pen(Brushes.Black, 0.2), new Rect(0, 0,
m_BoardSize * m_BoardWidthFactor + m_Border * 2,
m_BoardSize * m_BoardHeightFactor + m_Border * 2));
dc.DrawRectangle(m_BoardBrush, new Pen(Brushes.Black, 0.2),
new Rect(m_Border, m_Border, m_BoardSize * m_BoardWidthFactor,
m_BoardSize * m_BoardHeightFactor));
for (int x = 0; x < m_BoardSize; x++)
{
for (int y = 0; y < m_BoardSize; y++)
{
dc.DrawRectangle(null, m_BlackPen, new Rect(getPosX(x), getPosY(y),
m_BoardWidthFactor, m_BoardHeightFactor));
}
}
}
}
After creating the DrawingVisual
, we can now draw to the DrawingContext
which is created by the RenderOpen()
method. DrawingContext
implements the IDisposeable
interface and uses the Close()
method to flush the content.
After drawing two rectangles (one for the complete board with borders, one for the actual board without borders), we draw boardSize * boardSize
rectangles. That's all we have to do. The cool thing about WPF is that it automatically scales all visuals. We only work with our 14 to 15 ratio and that's it!
public void DrawStones()
{
m_BoardVisual.Children.Remove(m_StonesVisual);
m_StonesVisual = new DrawingVisual();
using (DrawingContext dc = m_StonesVisual.RenderOpen())
{
foreach (var item in m_StoneList)
{
double posX = getPosX(item.Key.X);
double posY = getPosY(item.Key.Y);
dc.DrawEllipse(m_StoneShadowBrush, null, new Point(posX + 1, posY + 1),
6.7, 6.7);
dc.DrawEllipse(((
item.Value == Stone.White) ? m_WhiteStoneBrush : m_BlackStoneBrush),
m_BlackPen, new Point(posX, posY), m_BoardWidthFactor / 2 - 0.5,
m_BoardWidthFactor / 2 - 0.5);
}
}
m_BoardVisual.Children.Add(m_StonesVisual);
}
In DrawStones()
, you can see something that I used for every subvisual of m_BoardVisual
. First the visual removes itself and after the drawing is finished, it adds itself again. While iterating through the stoneList
Dictionary, we translate the stoneposition
(which is from Type GoBoardPoint
and has an X and Y coordinate) to the visual position. Then we draw a small shadow and then the actual stone. The Brush
for a black stone looks like this:
<RadialGradientBrush Center="0.3,0.3" GradientOrigin="0.3,0.3" Opacity="1"
x:Key="blackStoneBrush">
<RadialGradientBrush.GradientStops>
<GradientStop Color="Gray" Offset="0"/>
<GradientStop Color="Black" Offset="1"/>
</RadialGradientBrush.GradientStop>
</RadialGradientBrush>
Adding Some WPF Functionality
Ok. Now we have drawn the board, but we need some functionality like binding to the boardsize or a click event. The boardsize is implemented via a DependencyProperty
. The MovePlayed
event is implemented via a RoutedEvent
.
BoardSize Implementation
The standard size is 19. Only values between 2 and 19 are valid. After the boardsize changes, the board is reinitialized.
public static readonly DependencyProperty BoardSizeProperty =
DependencyProperty.Register
("BoardSize", typeof(int), typeof(GoBoardPainter),
new FrameworkPropertyMetadata
(19, new PropertyChangedCallback(OnBoardSizeChanged)),
new ValidateValueCallback(BoardSizeValidateCallback));
public int BoardSize
{
get { return (int)GetValue(BoardSizeProperty); }
set { SetValue(BoardSizeProperty, value); }
}
private static bool BoardSizeValidateCallback(object target)
{
if ((int)target < 2 || (int)target > 19)
return false;
return true;
}
private static void OnBoardSizeChanged(DependencyObject sender,
DependencyPropertyChangedEventArgs args)
{
(sender as GoBoardPainter).InitializeBoard
((sender as GoBoardPainter).BoardSize - 1);
}
MovePlayed Event Implementation
MovePlayedEvent
is a Bubble Event. In OnMouseLeftButtonDown
, we check if the click is inside the HitBox
and then raise the event.
public static readonly RoutedEvent MovePlayedEvent =
EventManager.RegisterRoutedEvent("MovePlayed", RoutingStrategy.Bubble,
typeof(MovePlayedEventHandler), typeof(GoBoardPainter));
public delegate void MovePlayedEventHandler(object sender,
RoutedMovePlayedEventArgs args);
public event MovePlayedEventHandler MovePlayed
{
add { AddHandler(MovePlayedEvent, value); }
remove { RemoveHandler(MovePlayedEvent, value); }
}
protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
{
base.OnMouseLeftButtonDown(e);
Point pos = e.GetPosition(this);
if (!m_GoBoardHitBox.Contains(new Point
(pos.X - m_Border, pos.Y - m_Border))) return;
int x = (int)Math.Round((pos.X - m_Border) /
(m_GoBoardRect.Width / m_BoardSize));
int y = (int)Math.Round((pos.Y - m_Border) /
(m_GoBoardRect.Height / m_BoardSize));
RaiseEvent(new RoutedMovePlayedEventArgs
(MovePlayedEvent, this, new Point(x, y),
m_ToPlay));
}
Putting It All Together
To show you some of the functionality in action, I created a small demo application which has the following XAML code:
<Window x:Class="GoBoard.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:goBoard="clr-namespace:GoBoard.UI"
Title="Window1" Height="500" Width="400">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition/>
</Grid.RowDefinitions>
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Slider Maximum="19" Minimum="2" Value="19" Name="slBoardSize"
VerticalAlignment="Center" Margin="5" ></Slider>
<StackPanel Grid.Column="1" Margin="5">
<RadioButton Name="rdStone" IsChecked="True"
Checked="rdStone_Checked">Stone</RadioButton>
<RadioButton Name="rdRectangle"
Checked="rdRectangle_Checked">Rectangle</RadioButton>
<RadioButton Name="rdCircle"
Checked="rdRectangle_Checked">Circle</RadioButton>
</StackPanel>
</Grid>
<Viewbox Grid.Row="1">
<goBoard:GoBoardPainter
BoardSize="{Binding ElementName=slBoardSize, Path=Value}"
MouseHoverType="Stone"
x:Name="goBoardPainter"
MovePlayed="goBoardPainter_MovePlayed">
</goBoard:GoBoardPainter>
</Viewbox>
</Grid>
</Window>
As you can see, the boardsize
is binded to a Slider
value, thanks to the DependencyProperty
. Also the MovePlayed
event is registered in the code behind file. In this event handler, we check the radio buttons and add a stone or an annotation. The code looks like this:
private void goBoardPainter_MovePlayed(object sender,
RoutedMovePlayedEventArgs e)
{
if (rdStone.IsChecked.Value &&
!goBoardPainter.StoneList.ContainsKey(e.Position))
{
goBoardPainter.StoneList.Add(new GoBoardPoint
(e.Position.X, e.Position.Y),
e.StoneColor);
goBoardPainter.ToPlay = e.StoneColor ^ Stone.White;
}
else if (rdRectangle.IsChecked.Value)
{
goBoardPainter.AnnotationList.Add(new GoBoardAnnotation(
GoBoardAnnotationType.Rectangle, e.Position));
}
else if (rdCircle.IsChecked.Value)
{
goBoardPainter.AnnotationList.Add(new GoBoardAnnotation(
GoBoardAnnotationType.Circle, e.Position));
}
goBoardPainter.Redraw();
}
Conclusion
I hope you enjoyed my first article and also hope you learned something! I will cover a Go board with functional Go logic in a later article.
PS: My first language is not English.
If you want to play GO online, visit www.gokgs.com.
My Go rank is 2D.
History
- 11th February 2009 - Added first version