65.9K
CodeProject 正在变化。 阅读更多。
Home

入门 Silverlight:井字棋

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.50/5 (3投票s)

2009年7月6日

CPOL

21分钟阅读

viewsIcon

62934

downloadIcon

984

通过一个简单的井字游戏应用程序来学习 Silverlight。本文将解释如何通过创建一个包含人工智能的简单井字游戏,来构建你的第一个 Silverlight 应用程序和用户控件。

引言

井字游戏是一个已经流传了几个世纪的简单游戏。在英国被称为“noughts and crosses”,人们在可追溯至罗马帝国的石头上发现了刻有该游戏的井字格。由于其简单性,它成为计算机应用的一个很好的研究案例。这使得为计算机设计下棋算法变得容易。

井字游戏为构建一个简单、轻松而又有趣的 Silverlight 应用程序提供了机会。读完本文后,你应该对 Silverlight 的工作原理有一个很好的了解。你还将有机会进一步扩展和增强该应用程序。

概念

本文假设你至少具备 Silverlight 的基础知识。如果你需要入门介绍,我推荐 Silverlight 入门第一部分。微软在其网站 Silverlight.Net 上也提供了优秀的学习资源。

读完本文后,你应该能够:

  • 构建一个简单的 Silverlight 应用程序
  • 在 Silverlight 中格式化和定位元素
  • 使用 RenderTransform 旋转和缩放元素
  • 使用 Storyboard 为 Silverlight 元素添加动画
  • 使用异步计时器来驱动游戏逻辑
  • 理解使用委托进行依赖注入
  • 使用模板方法模式为游戏应用可变算法

入门

首先,创建一个示例 Silverlight 应用程序。我更喜欢使用 Visual Studio 2008 来做这件事,因为它会自动创建一个 Web 应用程序来托管 Silverlight 应用程序。当你在 Expression Blend 中创建项目时,它只会构建 Silverlight。无论哪种方式,当你构建项目时,一个存根的 “default.html” 文件会被放置在 Silverlight 项目的输出目录中,可用于托管该应用程序。

在编写任何代码之前,我们应该先退一步来设计我们的应用程序。在铺下第一块砖之前,最好先有一张蓝图。总体上思考一下这个应用程序,以及我们如何使用 Silverlight 实现井字游戏的概念。

应用程序

整个应用程序将为玩家提供三种游戏选项:简单、中等和困难。这样,小孩子可以愉快地玩“简单”模式,而更有经验的玩家则可以享受挑战计算机更高级算法的乐趣。应该有一种方式来开始新游戏,一种方式来输入走法,并且由于我将游戏托管在我的网站上,我还希望有一个返回主页的链接,这样用户就不会在游戏中“迷路”。

关注点分离

游戏的首要关键是算法。我们如何让计算机走一步棋?

井字游戏棋盘实际上是一个由行和单元格组成的矩阵。在这里,我用“行”作为一个通用术语,指代连续的单元格,无论它们是在实际的一行、一列还是对角线上。获胜的策略很简单:成为第一个用你的标记填满一整行三个连续单元格的人。

让我们从最基本的层面开始:一个空的棋盘。棋盘中的每个“单元格”都有一个状态。我们知道有三种状态:空单元格、带有“叉”或 X 的单元格,以及带有“圈”或 O 的单元格。我们可以用一个枚举来表示它:

public enum State
{
    None = 0,

    X = 1,

    O = 2
}

接下来,我们有“单元格”。首先,我们知道一个给定的单元格至少有三个属性:它有一个状态、一个行和一个在网格中的列。我们知道单元格在哪里,里面有什么。

这些单元格被组织在我称之为“矩阵”的结构中,它是一个 3x3 的单元格网格。

当我们开始考虑算法时,思考那些决定游戏是否获胜的“行”就变得很重要。如果一行被 X 或 O 填满,它对我们就变得重要了。为了创建算法,我们需要知道一行的状态,以决定我们是否想在那里放置我们的标记。

我们知道一行应该恰好包含三个单元格。关于一行还有什么重要的呢?

  • 它可能应该包含某种分数或“权重”,以确定它与其他行相比的重要性。
  • 我应该能看到行中有哪些单元格。
  • 一行可能有自己的“状态”,例如“已获胜”(全是 X 或 O)或“已和棋”(即行中同时有 X 和 O,因此不能用来赢得游戏)。

因为我们还处于规划的早期阶段,现在尝试为行评分没有意义。相反,我们将使用委托。我只想传入行中的单元格列表并返回一个分数。这足够简单:

public delegate int RowScoreStrategy(Cell[] cells);

现在我可以公开一个 Score 属性,它只需调用行评分策略并传入该行的单元格:

public int Score
{
    get
    {
        return _strategy(_rowCells);
    }
}

好了……现在我们有了一行的一些基本要素。实际上,我们已经足以构建 row 对象了。我在构造函数中接收评分策略和实际的单元格,然后公开一些属性来确定该行是否获胜或和棋,以及分数是多少:

using System.Collections.Generic;

namespace TicTacToe.Matrix
{
    public class Row
    {
        public Row(Cell cell1, Cell cell2, Cell cell3, RowScoreStrategy scoreStrategy)
        {
            _strategy = scoreStrategy;
            _rowCells = new[] { cell1, cell2, cell3 };
            cell1.AddRow(this);
            cell2.AddRow(this);
            cell3.AddRow(this);
        }

        private readonly Cell[] _rowCells;

        private readonly RowScoreStrategy _strategy;

        public int Score
        {
            get
            {
                return _strategy(_rowCells);
            }
        }

        public List<Cell> EmptyCells
        {
            get
            {
                List<Cell> retVal = new List<Cell>();
                foreach (Cell cell in _rowCells)
                {
                    if (cell.CellState.Equals(State.None))
                    {
                        retVal.Add(cell);
                    }
                }
                return retVal;
            }
        }

        public List<int> GetCells()
        {
            List<int> retVal = new List<int>();
            foreach (Cell cell in _rowCells)
            {
                retVal.Add(cell.CellRow * 3 + cell.CellCol);
            }
            return retVal;
        }

        public bool Won
        {
            get
            {
                return !_rowCells[0].CellState.Equals(State.None) &&
                       _rowCells[0].CellState.Equals(_rowCells[1].CellState) &&
                       _rowCells[1].CellState.Equals(_rowCells[2].CellState);
            }
        }

        public bool Drawn
        {
            get
            {
                return ((_rowCells[0].CellState.Equals(State.X) || 
			_rowCells[1].CellState.Equals(State.X) ||
                         	_rowCells[2].CellState.Equals(State.X)) &&
                        	(_rowCells[0].CellState.Equals(State.O) || 
			_rowCells[1].CellState.Equals(State.O) ||
                         	_rowCells[2].CellState.Equals(State.O)));
            }
        }
    }
}

在构建了行之后,我现在意识到我们或许应该继续为单个单元格评分。一种“纯粹”的方法显然是拥有一套单元格评分策略,但目前我满足于将单元格的分数定义为它所属所有行的分数之和。为此,我们将创建一个循环引用,因为行包含单元格,而单元格知道它们属于哪些行,但这只是一个双向对象图,如果我们不试图递归地从一种实体类型遍历到另一种,它将是安全的。

我们已经说过,我们的单元格会有一个状态和对矩阵中位置的引用。现在我们将添加一个单元格所属行的列表。我们通过将行集合设为private并公开一个只允许你向集合中添加行的方法来防止从行到单元格再到行的递归。最后,我们有了分数的概念(单元格所属所有行分数之和)以及该单元格是否是获胜行的一部分(通过检查该单元格所属的任何行是否“获胜”)。

这张图片应该有助于说明行的概念:

这是完整的 cell 类:

using System.Collections.Generic;

namespace TicTacToe.Matrix
{
    public class Cell
    {
        public Cell(int row, int col)
        {
            CellRow = row;
            CellCol = col;
        }

        public State CellState { get; set; }

        public int CellRow { get; private set; }

        public int CellCol { get; private set; }

        public int Score
        {
            get
            {
                int score = 0;
                foreach (Row row in _rows)
                {
                    score += row.Score;
                }
                return score;
            }
        }

        public bool Won
        {
            get
            {
                bool retVal = false;

                foreach (Row row in _rows)
                {
                    if (row.Won)
                    {
                        retVal = true;
                        break;
                    }
                }

                return retVal;
            }
        }

        private readonly List<Row> _rows = new List<Row>();

        public void AddRow(Row row)
        {
            _rows.Add(row);
        }
    }
}

现在我们可以组装实际的 Matrix 类,它将持有所有单元格,并负责走棋的策略。这使我们能够将“思考”和维护棋盘状态的逻辑完全封装在一个独立的类中,这个类与我们构建游戏界面的方式是解耦的(关于这一点,请参阅我的文章《SOLID 和 Dry 第一部分》和《第二部分》)。

矩阵本身很简单,反映了网格的样子:

private readonly Cell[][] _matrix = {
                                 new[] {new Cell(0, 0), new Cell(0, 1), new Cell(0, 2)},
                                 new[] {new Cell(1, 0), new Cell(1, 1), new Cell(1, 2)},
                                 new[] {new Cell(2, 0), new Cell(2, 1), new Cell(2, 2)}
                                    };

我们现在可以把单元格组织成我们的行:

private readonly Row[] _rows;

_rows = new[]
            {
                new Row(_matrix[0][0], _matrix[0][1], _matrix[0][2], rowStrategy),
                new Row(_matrix[1][0], _matrix[1][1], _matrix[1][2], rowStrategy),
                new Row(_matrix[2][0], _matrix[2][1], _matrix[2][2], rowStrategy),
                new Row(_matrix[0][0], _matrix[1][0], _matrix[2][0], rowStrategy),
                new Row(_matrix[0][1], _matrix[1][1], _matrix[2][1], rowStrategy),
                new Row(_matrix[0][2], _matrix[1][2], _matrix[2][2], rowStrategy),
                new Row(_matrix[0][0], _matrix[1][1], _matrix[2][2], rowStrategy),
                new Row(_matrix[0][2], _matrix[1][1], _matrix[2][0], rowStrategy),
            };

与行类似,我们也需要一个走棋的策略。同样,我们可以通过使用委托来推迟这一步,稍后再担心实际的算法。我们希望传入矩阵本身以及矩阵中的行,然后返回计算机希望下一步走的单元格。它看起来像这样:

public delegate Cell CellStrategy(Row[] rows, Cell[][] matrix);

现在我们可以先草拟一个快速简陋的策略,在处理更复杂的算法之前暂时使用,即只选择一个随机的空单元格:

public static CellStrategy EasyStrategyDelegate =
    (rows, matrix) =>
        {
            _CheckBounds(rows,matrix);

            List<Cell> available = new List<Cell>();

            for (int rowIdx = 0; rowIdx < 3; rowIdx++)
            {
                for (int colIdx = 0; colIdx < 3; colIdx++)
                {
                    if (matrix[rowIdx][colIdx].CellState.Equals(State.None))
                    {
                        available.Add(matrix[rowIdx][colIdx]);
                    }
                }
            }

            Cell retVal;

            lock (_random)
            {
                retVal = available[_random.Next(available.Count)];
            }

            return retVal;
        };

`check bounds` 方法只是确保我们得到了预期的结果,否则会抛出适当的异常:

private static void _CheckBounds(ICollection rows, ICollection matrix)
{
    if (rows == null)
    {
        throw new ArgumentNullException("rows");
    }

    if (rows.Count != 8)
    {
        throw new ArgumentOutOfRangeException("rows");
    }

    if (matrix == null)
    {
        throw new ArgumentNullException("matrix");
    }

    if (matrix.Count != 3)
    {
        throw new ArgumentOutOfRangeException("matrix");
    }
}

目前这样就足够了,让我们进入表示层,开始构建一个界面。

布局

井字游戏棋盘

首先要关注的是井字游戏棋盘本身。我们可以制作一个简单的按钮网格,方便用户点击,当按钮有内容后就禁用它们。网格应该使每个按钮大小相同,并根据用户的显示设置进行缩放。

这个的 XAML 很直观 — 注意,我根据按钮所在的行和列为它们命名,以便之后轻松引用:

<UserControl
	xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
	xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
	xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
	xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
	xmlns:TicTacToe="clr-namespace:TicTacToe"
	mc:Ignorable="d"
	x:Class="TicTacToe.TicTacoToeMain"
	d:DesignWidth="640" d:DesignHeight="480">
    <UserControl.Resources>
        <Style x:Key="BigButton" TargetType="Button">
            <Setter Property="FontSize" Value="50"/>
            <Setter Property="FontWeight" Value="Bold"/>
        </Style>
    </UserControl.Resources>
	<Grid x:Name="LayoutRoot">
		<Grid.RowDefinitions>
			<RowDefinition Height="0.333*"/>
			<RowDefinition Height="0.333*"/>
			<RowDefinition Height="0.334*"/>
		</Grid.RowDefinitions>
		<Grid.ColumnDefinitions>
			<ColumnDefinition Width="0.333*"/>
			<ColumnDefinition Width="0.333*"/>
			<ColumnDefinition Width="0.334*"/>
		<Grid.ColumnDefinitions>
		<Button x:Name="b00" HorizontalAlignment="Stretch" 
			VerticalAlignment="Stretch"
			Content="Click Me" Margin="3,3,3,3" Grid.Row="0"
			Grid.Column="0" Style="{StaticResource BigButton}"/>
        <Button x:Name="b10" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
        	Content="Click Me" Margin="3,3,3,3" Grid.Row="1"
        	Grid.Column="0" Style="{StaticResource BigButton}"/>
        <Button x:Name="b20" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
        	Content="Click Me" Margin="3,3,3,3" Grid.Row="2"
        	Grid.Column="0" Style="{StaticResource BigButton}"/>
        <Button x:Name="b01" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
        	Content="Click Me" Margin="3,3,3,3" Grid.Row="0"
        	Grid.Column="1" Style="{StaticResource BigButton}"/>
        <Button x:Name="b11" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
        	Content="Click Me" Margin="3,3,3,3" Grid.Row="1"
        	Grid.Column="1" Style="{StaticResource BigButton}"/>
        <Button x:Name="b21" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
        	Content="Click Me" Margin="3,3,3,3" Grid.Row="2"
        	Grid.Column="1" Style="{StaticResource BigButton}"/>
        <Button x:Name="b02" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
        	Content="Click Me" Margin="3,3,3,3" Grid.Row="0"
        	Grid.Column="2" Style="{StaticResource BigButton}"/>
        <Button x:Name="b12" HorizontalAlignment="Stretch"
        	VerticalAlignment="Stretch" Content="Click Me" Margin="3,3,3,3"
        	Grid.Row="1" Grid.Column="2" Style="{StaticResource BigButton}"/>
        <Button x:Name="b22" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
        	Content="Click Me" Margin="3,3,3,3" Grid.Row="2"
        	Grid.Column="2" Style="{StaticResource BigButton}"/>
        <TicTacToe:GameOver x:Name="GameOverSplash"
        	Grid.ColumnSpan="3" Grid.RowSpan="3" Visibility="Collapsed"/>
    </Grid>
</UserControl>

让我们来分解一下。

任何元素都可以包含自己的资源,你只需使用 element.resources 来嵌入它们。我们为按钮包含了一个简单的 Style。这个样式名为 BigButton,目标是 Button 类型的元素。我们只是将字体大小设置为 50 像素,字重设置为粗体。在按钮本身中,你会看到这个属性:

Style="{StaticResource BigButton}"

大括号表示法是 XAML 嵌入特殊指令的方式。在这种情况下,它只是意味着*这个 XAML 中嵌入了一个名为 BigButton 的静态资源,我希望你使用它*。就这么简单!资源的范围可以从元素级别一直到主控件。

网格(grid)是更常用的布局元素之一。你可以定义行和列,然后定义相对于每个行和列定位的子元素。在这种情况下,我们只声明了三个“几乎”大小相等的行和列。小数形式的宽度或高度将按父级尺寸的百分比进行缩放。

每个按钮在网格单元格内都有一个填充(margin,注意如果你只输入一个值,它会自动应用于所有维度,这里我为你明确列出了)。

  • 自学点子:扩展样式以包含一个“模板”,并将“Click Me”内容放入模板中,这样就不用为每个按钮重复。同时,使用样式来设置垂直和水平对齐以及边距。

你会注意到对 “GameOverSplash” 的引用。这是我们定义的另一个控件。你可以看到我们在顶部声明了如何找到该控件:

xmlns:TicTacToe="clr-namespace:TicTacToe"

如果命名空间在不同的程序集中,你只需添加一个带有完全限定程序集名称的 “assembly=” 指令。

现在运行时知道在哪里找到 TicTacToe.GameOver。你会注意到我们让它跨越整个网格,并且它开始时是不可见的。

游戏结束

游戏结束控件在每局游戏会话结束时显示。它是一个简单的控件:

<UserControl x:Class="TicTacToe.GameOver"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    Width="Auto" Height="Auto">
    <UserControl.Resources>
        <Storyboard x:Name="GameOverFade">
            <DoubleAnimation Storyboard.TargetName="LayoutRoot"
            	Storyboard.TargetProperty="Opacity" From="0.0" 
		To="0.90" Duration="0:0:5"/>
        </Storyboard>
    </UserControl.Resources>
    <Grid x:Name="LayoutRoot" Background="Gray" Opacity="0.75"
    	HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
        <Border x:Name="GameOverBorder"  CornerRadius="30"  Width="400" Height="200"
        	Opacity="1.0" VerticalAlignment="Center" HorizontalAlignment="Center">
            <TextBlock FontSize="50" FontWeight="Bold" Text="{Binding}"
            	HorizontalAlignment="Center" 
		VerticalAlignment="Center" TextWrapping="Wrap"/>
        </Border>
    </Grid>
</UserControl>

视觉元素包括一个不透明度为 75% 并设置为拉伸的网格。这是我们的“模态对话框”风格。它将覆盖整个网格并使其变暗,以便用户知道棋盘已被禁用。在网格的中间,我们有一个带圆角的边框。它将覆盖在模态背景之上并显示我们的消息。消息在一个文本块中。请注意这个:

Text="{Binding}" 

我们将把我们的文本绑定到控件本身,然后无论我们把控件的数据上下文设置成什么,都会被传递到文本框中。

你会注意到在控件的顶部声明了一个 Storyboard 资源。故事板只是可以应用的一组动画或过渡。在我们的例子中,我们使用一个 DoubleAnimation(这不意味着“两个”,而是指该动画将用于操作一个 double 类型的值)。在这里,我们将控件的不透明度从 0(不可见)变为 0.9(几乎“透视”)。持续时间设置为 5 秒,以实现缓慢的淡入效果。

当游戏结束时,我们让控件可见。例如,如果游戏是平局,我们将文本 "Draw" 绑定到控件,将弹出窗口背景设置为黄色,然后启动故事板使其淡入。这个序列看起来像这样:

GameOverSplash.DataContext = "Draw.";
SolidColorBrush yellowBrush = new SolidColorBrush { Color = Colors.Yellow };
GameOverSplash.GameOverBorder.Background = yellowBrush;
GameOverSplash.Visibility = Visibility.Visible;
Storyboard fade = GameOverSplash.Resources["GameOverFade"] as Storyboard;
if (fade != null)
{
    fade.Begin();
}

主应用程序

最后,我们可以布局主应用程序。我设置了一个网格,顶部有固定的高度。我将在这里放置用于决定游戏难易程度、开始新游戏以及点击进入我网站的控件。

按钮的 width 固定为 100 像素,字体大小为 20

<Style x:Name="ControlButton" TargetType="Button">
    <Setter Property="Width" Value="100"/>
    <Setter Property="FontSize" Value="20"/>
</Style>

为了显示选择了哪个难度级别,我将通过保持较低的不透明度,使所有按钮看起来“模糊”:

<Button x:Name="MediumButton" Opacity="0.4" Click="Control_Click" Content="Medium"
	Background="Yellow" Foreground="Orange" 
	Style="{StaticResource ControlButton}"/>

然后,当按钮被点击时,我们将不透明度设置为完全不透明 (1.0)。

对于“新游戏”按钮,我使用了一种名为 StackPanel 的新布局控件。StackPanel 将元素并排排列,可以是左右(水平)或上下(垂直)。在这种情况下,我使用 StackPanel 来定位和居中我的两行文本:

<Button Style="{StaticResource ControlButton}" Click="NewGame_Click">
   <StackPanel Orientation="Vertical">
      <TextBlock Text="New" HorizontalAlignment="Center"/>
      <TextBlock Text="Game" HorizontalAlignment="Center"/>
   </StackPanel>
</Button>

Silverlight 的一个很棒的方面是你可以变换任何被渲染的东西。这意味着即使是由各种其他元素构建的复合对象,也可以随心所欲地进行缩放、变换、旋转和倾斜。为了演示这一点,我将指向我自己网站的链接定位成一个倾斜的链接:

<HyperlinkButton Margin="10" NavigateUri="http://jeremylikness.com/">
   <TextBlock Text="JeremyLikness.com" FontSize="15">
      <TextBlock.RenderTransform>
         <RotateTransform Angle="15"/>
      </TextBlock.RenderTransform>
   </TextBlock>
</HyperlinkButton>

如你所见,像 ButtonHyperlinkButton 这样的元素可以附加像文本这样简单的即时内容,也可以包含提供完整格式化功能的嵌套内容。这是一个非常强大的 UI 范式。

为了增加一点乐趣,我加入了我的签名标志,并为游戏介绍添加了动画。该标志会放大并看似旋转着消失到远处,然后游戏启动画面淡入。这一切都只需通过启动故事板动画来完成。标志的旋转动画是这样的:

<Image Height="80" HorizontalAlignment="Center" Margin="0,0,0,0" 
	VerticalAlignment="Center" Width="404" Opacity="1.0" 
	Source="Resources/signature_big.PNG" x:Name="ImageSignature">
    <Image.Resources>
        <Storyboard x:Name="LogoStoryboard">
            <DoubleAnimation Storyboard.TargetName="LogoRotate"
            	Storyboard.TargetProperty="Angle" From="0" To="360" Duration="0:0:5"/>
            <DoubleAnimation Storyboard.TargetName="LogoScale"
            	Storyboard.TargetProperty="ScaleX" From="2.0" 
		To="0.1" Duration="0:0:5"/>
            <DoubleAnimation Storyboard.TargetName="LogoScale"
            	Storyboard.TargetProperty="ScaleY" From="2.0" 
		To="0.1" Duration="0:0:5"/>
        </Storyboard>
    </Image.Resources>
    <Image.RenderTransform>
        <TransformGroup>
            <RotateTransform x:Name="LogoRotate" CenterX="202" CenterY="40"/>
            <ScaleTransform x:Name="LogoScale" CenterX="202" CenterY="40"/>
        </TransformGroup>
    </Image.RenderTransform>
</Image>

我会让你查看源代码来了解介绍性启动对话框的定义。在后台代码中,我们使用故事板事件将序列串联起来:

首先,连接到标志旋转器的 completed 事件并启动它:

LogoStoryboard.Completed += _LogoStoryboardCompleted;
LogoStoryboard.Begin();

当第一个动画完成时,只需隐藏图像并启动下一个动画:

ImageSignature.Visibility = Visibility.Collapsed;
PresentsStoryboard.Begin(); 

关于使用故事板,有几点需要注意。首先,如果你希望重用一个故事板,你必须在第一个周期完成后通过发出 "storyboard.Stop()" 调用来明确地“停止”它。其次,属性只在故事板持续期间被操纵。当故事板完成时,属性会恢复到它们的原始值,因此在故事板完成时设置“最终”值是很重要的,否则你的图像会“弹回”到它开始时的样子。

现在我们有了用户界面和棋盘本身,让我们来连接一些游戏玩法吧!

思考

井字游戏是一个简单的算法,因为每个玩家轮流独立行动。我们可以很容易地等待玩家输入文本,然后快速评估一个反击走法并将其放置在棋盘上。然而,为了让游戏更有趣,我想模拟一个真正的对手并加入一些延迟。

这需要一个 "think" 方法,它会定期触发,而不管玩家的交互如何。计算机应该知道是否该走一步了。我们还将使用这个“思考者”在未使用的网格单元格中显示一些消息。这为游戏增添了更多乐趣,也表明即使在玩家尚未走棋时,计算机也在积极思考。

这个“think”方法通常被称为“游戏循环”。虽然我们在这个特定的实现中使用了计时器,但像滚动和射击这样更耗费资源的游戏需要更频繁的处理。图形密集型游戏的一个有趣方面是,屏幕会快速连续渲染以创造运动的错觉。这些被称为“帧”。在一帧内重绘一个图形 5 次是没有意义的,因为它只向用户显示一次。

Silverlight 提供了一个可以挂钩到这些“帧”的接口,你可以用它作为目标来定期更新游戏的各个方面,并确保每次更新都会在一帧中反映出来。这就是 `Rendering` 事件,可以这样挂钩:

CompositionTarget.Rendering += _MyGameLoop; 

我们将改用计时器,因为我们不需要进行那么多密集的思考,而且我们希望在用消息更新按钮之前有一个延迟。让我们看看主井字棋盘的构造函数:

public TicTacoToeMain() : this(Strategies.EasyStrategyDelegate, Strategies.ScoreDelegate)
{

}

public TicTacoToeMain(CellStrategy cellStrategy, RowScoreStrategy rowScoreStrategy)
{
	InitializeComponent();

	_ticTacToeComputer = new Matrix.Matrix(cellStrategy, rowScoreStrategy);

	_buttons.Add(b00);
	_buttons.Add(b01);
	_buttons.Add(b02);
	_buttons.Add(b10);
	_buttons.Add(b11);
	_buttons.Add(b12);
	_buttons.Add(b20);
	_buttons.Add(b21);
	_buttons.Add(b22);

	foreach (Button button in _buttons)
	{
		button.Click += _Click;
	}

	DispatcherTimer thinkTimer = new DispatcherTimer 
			{ Interval = new TimeSpan(0, 0, 0, 5) };
	thinkTimer.Tick += _Think;
	thinkTimer.Start();
}

默认构造函数调用重载的构造函数,使用默认策略(简单策略)和默认的行评分策略(稍后会详细介绍)。然后,我们创建一个新的矩阵(记住,这是我们维护“棋盘状态”和评估走法的类),并将我们自己的按钮添加到一个数组中,以便轻松地将它们映射到矩阵。按钮被赋予了一个点击函数,以知道如何反应。最后,我们创建了一个 DispatcherTimer。它将在 5 秒后触发并调用 _Think 方法。

  • 旁注:Timespan 类有几个辅助方法来创建时间跨度。我们使用了接受时、分、秒等的构造函数,但另一种更易读的方式是:

    Interval = TimeSpan.FromSeconds(5)

因为我们简化了应用程序的设计,所以游戏循环函数非常直观。

我们有一个名为“stop thinking”的标志,在游戏结束时设置。计时器首先检查这个标志,如果存在,就停止自己再次触发:

private void _Think(object sender, EventArgs e)
{
    if (_stopThinking)
    {
        ((DispatcherTimer)sender).Stop();
        return;
    }

如果游戏没有结束并且轮到电脑走棋,它会锁定矩阵并调用矩阵来获取下一个单元格。矩阵根据指定的策略计算走法,并返回电脑选择的单元格的行和列。如果游戏结束,它还会返回 `true`。电脑走完棋后,设置标志表示轮到玩家走棋。

如果游戏没有结束,并且轮到玩家走棋,电脑只是随机选择一句引言,并将该引言分配给一个随机可用的按钮。然后它会随机选择一个时间再次“思考”(这就是为什么在玩家走棋后,电脑下子会有延迟的原因)。

当玩家点击一个按钮时,电脑首先在 `_Click` 方法中检查是否轮到玩家。如果轮到电脑,它会更新按钮,显示一条消息告诉玩家稍等,因为电脑还在思考。否则,按钮被标记,然后再次调用矩阵以指示玩家的走法。同样,矩阵将返回游戏是否结束。

_CheckWin 函数确定游戏是否结束,以及游戏是如何结束的。矩阵有一个方法可以返回获胜的行。如果返回了行,它会被高亮显示;否则我们知道游戏是平局,我们会高亮整个棋盘。游戏结束对话框的动画被触发,游戏玩法结束,直到玩家点击“新游戏”按钮。

动态注入控件

你可能已经注意到我们的主 Page 并不包含井字游戏控件。相反,该控件是在玩家点击“新游戏”按钮时创建的。插入该控件的步骤很简单:

  1. 清除游戏主显示区域中的所有内容。这意味着清除 Children 元素的集合。
  2. 实例化控件。在这种情况下,我们将我们的策略传递给构造函数。我们确保控件被设置为拉伸以填充空间。
  3. 最后,我们将控件添加到主控件的子元素中。

这样做可以确保每次都有一个“干净的开始”。之前显示的所有内容——包括标志动画或上一局游戏——都会在开始新游戏之前被清除。代码如下:

private void NewGame_Click(object sender, RoutedEventArgs e)
{
    Main.Children.Clear();
    UserControl ticTacToe = new TicTacoToeMain(_strategy, Strategies.ScoreDelegate)
    {
        HorizontalAlignment = HorizontalAlignment.Stretch,
        VerticalAlignment = VerticalAlignment.Stretch
    };
    Main.Children.Add(ticTacToe);
}

算法

点击任何一个按钮:简单、中等或困难,都会为下一局游戏设置相应的策略。这是通过声明委托来完成的:

private CellStrategy _strategy = Strategies.EasyStrategyDelegate;

然后根据难度级别进行赋值:

private void Control_Click(object sender, RoutedEventArgs e)
{
    _ResetButtons();
    Button button = (Button) sender;
    button.Opacity = 1.0;
    if (button == EasyButton)
    {
        _strategy = Strategies.EasyStrategyDelegate;
    }
    else if (button == MediumButton)
    {
        _strategy = Strategies.MediumStrategyDelegate;
    }
    else
    {
        _strategy = Strategies.HardStrategyDelegate;
    }
}

注意,所有按钮都设置为低不透明度(0.4),然后选中的按钮被设置为完全不透明以高亮显示,并设置相应的委托。那么这些委托在哪里呢?

对于此应用程序,有三种策略。一种策略忽略任何类型的“行评分”,而另外两种使用相同的策略。简单级别只是选择一个随机单元格,这在文章前面已经定义过。那么中等和困难级别呢?

首先,我们来关注行的评分策略。你可以研究像*极小化极大算法*和*Alpha-Beta剪枝*这样的术语并应用它们,或者你可以遵循我个人最喜欢的哲学:“保持简单,傻瓜”(KISS)。

那么,让我们用一些简单的逻辑来确定我们的算法。是什么让一行对我们来说“有价值”?

  1. 同时有“X”和“O”的一行没有价值,这是一个“和棋”行,因为没有人能赢下它。
  2. 只有“X”或只有“O”的一行对我们很重要,因为我们可以借此建立潜在的胜利,或者通过将其变成和棋行来阻止我们的对手。
  3. 一行中有两个我们对手的标记几乎是最重要的一行,因为如果我们不在那一行上落子,我们的对手很可能会赢。
  4. 最重要的一行是含有我们自己两个标记的行,因为在那里落子就能赢得比赛。
  5. 哦,我们不能忘记空行:它比和棋行更重要,但比任何其他行都次要。

现在我们可以根据相对重要性简单地赋一些值。我是这样做的:

  • 0分 — 和棋行
  • 1分 — 空行
  • 10分 — 有两个同类型标记的行
  • 100分 — 有两个对手标记的行
  • 110分 — 有两个我方标记的行

我们所有的策略都封装在一个名为 Strategiesstatic 类中。我们已经定义了给行评分的委托,所以让我们把我们的算法连接进去:

public static RowScoreStrategy ScoreDelegate =
    (cells) =>
        {
            int score = 0;

            int empty = 0;
            int x = 0;
            int o = 0;

            for (int idx = 0; idx < cells.Length; idx++)
            {
                switch (cells[idx].CellState)
                {
                    case (State.None):
                        empty++;
                        break;
                    case (State.X):
                        x++;
                        break;
                    case (State.O):
                        o++;
                        break;
                    default:
                        break;
                }
            }

            if (empty == 3) // empty row
            {
                score = 1;
            }
            else if (empty == 2) // only an x or an o
            {
                score = 10;
            }
            else if (empty == 1) // only one slot
            {
                if (o == 2) // next move is a win
                {
                    score = 110;
                }
                else if (x == 2) // next move is a block
                {
                    score = 100;
                }
            }

            return score;
        };

可能有一些聪明的方法来分配加权值并使用掩码或其他逻辑来给行评分,但这种方法对我来说效果足够好,并且清晰地映射到我的算法。我只是计算每种类型单元格的数量,然后使用一个 if...else 树来评分。

那么我们决定电脑走法的单元格策略呢?你已经看到了一种策略,那就是选择一个可用的随机单元格(“简单”策略)。既然我们有了行的评分策略,“中等”游戏策略将是找到得分最高的一行,然后在该行中随机选择一个单元格。“困难”策略将是找到基于其所有行分数之和得分最高的单个单元格。一个是可战胜的,另一个则不是。

首先,找到得分最高的一行,找到符合条件的单元格,并随机返回其中一个单元格:

public static CellStrategy MediumStrategyDelegate =
    (rows, matrix) =>
        {
            // first find one of the rows with the highest score
            int highVal = 0;

            List<Row> eligibleRows = new List<Row>();

            foreach (Row matrixRow in rows)
            {
                int score = matrixRow.Score;

                if (score >= highVal)
                {
                    if (score == highVal)
                    {
                        eligibleRows.Add(matrixRow);
                    }
                    else
                    {
                        highVal = score;
                        eligibleRows.Clear();
                        eligibleRows.Add(matrixRow);
                    }
                }
            }

            int rowIdx = _random.Next(eligibleRows.Count);

            Row targetRow = eligibleRows[rowIdx];

            int cellIdx = _random.Next(targetRow.EmptyCells.Count);
            return targetRow.EmptyCells[cellIdx];
        };

注意,当行分数相同时,我们随机选择一行(这就是为什么匹配的分数会被添加到合格行列表中,而更高的分数会重置列表并单独添加)。

“困难”策略利用了我们已经为单元格添加了一个函数,该函数可以显示基于其所属所有行分数之和的分数,因此我们可以找到最具战略意义的单元格。下图展示了一个电脑获胜的假设游戏。玩家是“X”。每一帧都显示了玩家的走法,得分最高的行用黄色表示,得分最高的单元格也用黄色表示,这就是电脑将要走的位置。你可以看到它如何迅速抢占具有战略意义的中心单元格(因为它与最多的行相交,分数更高),然后利用玩家没有注意到那一行已经有两个“O”标记的失误。

因此,困难策略最终看起来像这样:获取所有符合条件的单元格,找到得分最高的单元格,然后选择一个。

public static CellStrategy HardStrategyDelegate =
    (rows, matrix) =>
        {
            // first find one of the rows with the highest score
            int highVal = 0;
            List<Cell> eligibleCells = new List<Cell>();

            for (int rowIdx = 0; rowIdx < 3; rowIdx++)
            {
                for (int colIdx = 0; colIdx < 3; colIdx++)
                {
                    Cell cell = matrix[rowIdx][colIdx];
                    if (cell.CellState.Equals(State.None))
                    {
                        eligibleCells.Add(cell);
                    }
                }
            }

            List<Cell> targetCells = new List<Cell>();

            foreach (Cell cell in eligibleCells)
            {
                int score = cell.Score;
                if (score >= highVal)
                {
                    if (score == highVal)
                    {
                        targetCells.Add(cell);
                    }
                    else
                    {
                        highVal = score;
                        targetCells.Clear();
                        targetCells.Add(cell);
                    }
                }
            }
            int cellIdx = _random.Next(targetCells.Count);
            return targetCells[cellIdx];
        };

值得考虑的要点

该应用程序是一个基本的起点,但可以通过多种方式进行扩展。以下是一些将你的知识提升到新水平的想法:

  • 将应用程序移植到 Silverlight 3.0,并利用其新特性。
  • 当点击新游戏按钮时添加一个确认弹窗,以避免意外终止正在进行的游戏。
  • 为主按钮创建自己的样式或模板,以支持文本换行并嵌入更有趣的字体。
  • 实现允许电脑先走一步的功能。
  • 将网格扩展到更复杂的网格,如 4x4 或更多单元格。
  • 创建一个允许多个玩家的客户端/服务器版本。
  • 找一些更有趣的引言。

这些只是基于这个原始概念构建项目的一些想法。

摘要

总而言之,你现在拥有一个功能齐全的应用程序,且关注点分离清晰。实际上,我们可以更进一步,将评分逻辑与领域模型完全分离,但在这种情况下,策略与数据紧密耦合,所以这样做似乎没有意义。我们得以探索布局、变换和动画,同时构建了一个简单的游戏循环,甚至创建了通过依赖注入加载的策略来帮助电脑进行游戏。

希望你和我一样已经了解到,Silverlight 是一个非常强大而灵活的框架,它模块化、可扩展,并且非常适合企业级应用程序开发。

如果你好奇“困难”算法是否真的无懈可击,可以在这里亲自在线尝试一下。

Jeremy Likness
© . All rights reserved.