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

ReversiEight - Windows 8 Reversi 游戏

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (36投票s)

2013年5月20日

CPOL

11分钟阅读

viewsIcon

48762

downloadIcon

711

描述了一款 Windows 8 翻转棋游戏的开发过程,涵盖了 UI 设计、Minimax 算法以及 Linq 的有趣用法。

目录   

引言

自从上次接触 Windows 8 已经有一段时间了,所以我想尝试创建一个简单的游戏应用——在这篇文章中,我分享了我的成果。本文描述了游戏的 MVVM 结构,但也涵盖了各种主题,包括

  • 如何高效处理 Windows 8 应用的各种布局需求。
  • 使用 GIMP 设计 UI。
  • 使用委托和一些 Linq 来简化游戏逻辑
  • 使用 Minimax 算法创建电脑对手。

基本视图模型结构

翻转棋游戏由 GameBoardViewModel “支持”,它通过一系列属性(如当前得分、下一个轮到的玩家以及棋盘本身的状态,作为 GameBoardSquareViewModel 实例的集合)来公开游戏状态。

public class GameBoardViewModel : ViewModelBase
{

  public GameBoardViewModel()
  {
    _squares = new List<GameBoardSquareViewModel>();
    for (int col = 0; col < 8; col++)
    {
      for (int row = 0; row < 8; row++)
      {
        _squares.Add(new GameBoardSquareViewModel(row, col, this));
      }
    }

    InitialiseGame();
  }

  public int BlackScore
  {
    get { return _blackScore; }
    private set
    {
      SetField<int>(ref _blackScore, value, "BlackScore");
    }
  }

  public int WhiteScore
  {
    get { return _whiteScore; }
    private set
    {
      SetField<int>(ref _whiteScore, value, "WhiteScore");
    }
  }

  public List<GameBoardSquareViewModel> Squares
  {
    get { return _squares; }
  }

  public BoardSquareState NextMove
  {
    get { return _nextMove; }
    private set
    {
      SetField<BoardSquareState>(ref _nextMove, value, "NextMove");
    }
  }

  /// <summary>
  /// Sets up the view model to the initial game state.
  /// </summary>
  private void InitialiseGame()
  {
    foreach (var square in _squares)
    {
      square.State = BoardSquareState.EMPTY;
    }

    GetSquare(3, 4).State = BoardSquareState.BLACK;
    GetSquare(4, 3).State = BoardSquareState.BLACK;
    GetSquare(4, 4).State = BoardSquareState.WHITE;
    GetSquare(3, 3).State = BoardSquareState.WHITE;

    NextMove = BoardSquareState.BLACK;

    WhiteScore = 0;
    BlackScore = 0;
  }
} 

ViewModelBase 类是一个相当标准的视图模型基类,它通过 SetField 方法简化了创建实现 INotifyPropertyChanged 的类的过程。GameBoardViewModel 构造函数创建了 8x8 的棋盘格,而 InitialiseGame 方法设置了初始状态(有关游戏的规则,包括初始设置,请参阅维基百科 规则)。

GameBoardSquareViewModel 公开了单个棋盘格的行、列和当前状态。

 public class GameBoardSquareViewModel : ViewModelBase
{
  private BoardSquareState _state;

  private GameBoardViewModel _parent;

  public GameBoardSquareViewModel(int row, int col, GameBoardViewModel parent)
  {
    Column = col;
    Row = row;
    _parent = parent;
  }

  public int Column { get; private set; }

  public int Row { get; private set; }

  public BoardSquareState State 
  {
    get { return _state; }
    set { SetField<BoardSquareState>(ref _state, value, "State"); } 
  }
} 

状态由以下枚举描述

public enum BoardSquareState
{
  EMPTY,
  BLACK,
  WHITE
}

创建游戏图形

Windows 8 应用的普遍风格是扁平化的“metro”风格。但是,对于这款翻转棋游戏,我想创造一些更具视觉冲击力的东西,所以我选择了 iPad 上更常见的伪现实风格。我还能说什么呢?有时候我喜欢木头、皮革、阴影和拉丝金属的外观!

我使用免费的 GIMP 绘图应用程序设计了应用程序 UI。GIMP 是一个跨平台图形包,提供了 PhotoShop 中的许多核心功能。我包含了 XCF 文件,其中显示了构成最终图像的各种图层。在本文中,我将简要概述我创建图形的步骤。

第一步是找到棋盘的木质背景。这通过搜索“桃花心木”的 Google 搜索实现,浏览结果后,我找到了我想要的背景。

下一步是“抠出”边框。这涉及到选择一个矩形区域,然后使用“圆角矩形”和“收缩”功能创建一个可以用来从背景中抠出边框的选择区域。边框被粘贴为一个新图层,然后应用一些微妙的颜色调整来提亮木纹,并添加了一个阴影。

棋盘的背景被创建为一个新图层,并在此图层中,使用与构建边框相同的选择区域填充为绿色。此外,还应用了一个微妙的噪点渲染,以赋予棋盘皮革质感。

在棋盘上方添加了一个包含径向渐变的图层,以产生以下阴影效果。

网格图案是通过构建一个 8x8 像素的图层来创建的,然后手动填充交替像素为黑色以创建网格。然后将此图层缩放到棋盘大小(不进行平滑处理),以创建正确大小的网格。网格图层的不透明度降低到约 20%,产生以下效果。

各种游戏文本使用“Script MT”字体渲染,并带有阴影。最后,通过组合圆形选区、阴影和浮雕效果绘制了几个棋子。

这是完成的游戏图形。

创建视图 (View)

由于 Windows 8 设备覆盖了各种不同的屏幕尺寸,因此无法使用单个图像作为应用程序的视图。为了构建翻转棋棋盘的视图,我拆解了游戏图形的各个组件,并使用网格布局重新组合它们。

Windows 8 应用程序的另一项要求是能够优雅地处理各种应用程序视图状态,包括纵向、横向、快照和填充。对于翻转棋游戏,为了优雅地适应这些不同的状态,我想改变各个组件的位置。例如,在横向模式下,将得分显示在棋盘的右侧,而在纵向模式下,则将得分显示在棋盘下方。

Windows 8 示例应用程序有一个类 LayoutAwarePage,它将视图状态更改转换为视觉状态,从而可以通过 VisualStateManager 在 XAML 中完全处理这些各种状态。

为了创建可重用的 UI 组件,通常会构建用户控件。但是,如果您只想重用相同的 XAML 标记,而不添加任何额外的功能或行为,那么通过 ContentControl 可以提供一种更简单的方法。

ReversiView 定义了如下一些模板。

<ControlTemplate x:Key="BlackScoreTemplate">
  <Viewbox>
    <Grid Width="150" Height="150">
      <Image Source="Assets/BlackPiece.png" Stretch="Uniform"/>
      <TextBlock Text="{Binding BlackScore}" VerticalAlignment="Center" HorizontalAlignment="Center"
                  FontSize="50"/>
    </Grid>
  </Viewbox>
</ControlTemplate>
    
<ControlTemplate x:Key="RestartGameTemplate">
  <Button Visibility="{Binding Path=GameOver, Converter={StaticResource BoolToVisibilityConverter}}"
          Command="{Binding Path=RestartGame}"
          Template="{StaticResource PlainButtonTemplate}">
    <Image Source="Assets/GameOver.png"
            Stretch="Uniform" Margin="40"
            VerticalAlignment="Top"/>
  </Button>
</ControlTemplate>
    
<ControlTemplate x:Key="ReversiTitleTemplate">
  <Image Source="Assets/ReversiText.png"
          Stretch="Uniform"
          Margin="0,80,0,0"/>
</ControlTemplate>
    
<ControlTemplate x:Key="WhiteScoreTemplate">
  <Viewbox>
    <Grid Width="150" Height="150">
      <Image Source="Assets/WhitePiece.png" Stretch="Uniform"/>
      <TextBlock Text="{Binding WhiteScore}" VerticalAlignment="Center" HorizontalAlignment="Center"
                  FontSize="50" Foreground="Black"/>
    </Grid>
  </Viewbox>
</ControlTemplate>

<ControlTemplate x:Key="ReversiBoardTemplate">
  <Viewbox>
    <Grid Margin="20">
      <Image Source="Assets/Board.png" Stretch="UniformToFill"/>
      <ItemsControl ItemsSource="{Binding Squares}">
        <ItemsControl.ItemsPanel>
          <ItemsPanelTemplate>
            <Grid Width="1200" Height="1200" x:Name="boardContainer"
                    common:GridUtils.RowDefinitions="*,*,*,*,*,*,*,*"
                    common:GridUtils.ColumnDefinitions="*,*,*,*,*,*,*,*">
            </Grid>
          </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
        <ItemsControl.ItemTemplate>
          <DataTemplate>
            <local:BoardSquareView Grid.Row="{Binding Row}" Grid.Column="{Binding Column}"
                                      Width="150" Height="150"/>
          </DataTemplate>
        </ItemsControl.ItemTemplate>
      </ItemsControl>
    </Grid>
  </Viewbox>
</ControlTemplate> 

这些模板中的每一个都定义了一个可重用的 UI 组件,其中每一个都使用 PNG 文件,该文件通过 GIMP 图形中的多个图层进行渲染。请注意,其中一些模板使用了 Viewbox 控件,它对于缩放应用程序 UI 以适应不同屏幕尺寸非常有用。

ReversiView 的内容如下。

 <Grid>

  <VisualStateManager.VisualStateGroups>
    <VisualStateGroup x:Name="ApplicationViewStates">
      <VisualState x:Name="FullScreenLandscape">
        <Storyboard>
          <ObjectAnimationUsingKeyFrames Storyboard.TargetName="FilledLayout" Storyboard.TargetProperty="Visibility">
            <DiscreteObjectKeyFrame KeyTime="0" Value="Collapsed"/>
          </ObjectAnimationUsingKeyFrames>
          <ObjectAnimationUsingKeyFrames Storyboard.TargetName="PortraitLayout" Storyboard.TargetProperty="Visibility">
            <DiscreteObjectKeyFrame KeyTime="0" Value="Collapsed"/>
          </ObjectAnimationUsingKeyFrames>
          <ObjectAnimationUsingKeyFrames Storyboard.TargetName="LandscapeLayout" Storyboard.TargetProperty="Visibility">
            <DiscreteObjectKeyFrame KeyTime="0" Value="Visible"/>
          </ObjectAnimationUsingKeyFrames>
        </Storyboard>
      </VisualState>
      ... further visual states omitted ...
    </VisualStateGroup>
  </VisualStateManager.VisualStateGroups>
    
  <Image Source="Assets/Background.jpg" Stretch="UniformToFill"/>

  <!--Portrait / snapped layout -->
  <Grid x:Name="PortraitLayout"
        common:GridUtils.ColumnDefinitions="*,*"
        common:GridUtils.RowDefinitions="*,3*,0.5*,*">
    <ContentControl Grid.ColumnSpan="2"
                    Template="{StaticResource ReversiTitleTemplate}"/>
    <ContentControl Grid.ColumnSpan="2" Grid.Row="1"
                    Template="{StaticResource ReversiBoardTemplate}"/>
    <ContentControl VerticalAlignment="Bottom" Grid.Row="2"
                    Template="{StaticResource BlackScoreTemplate}"/>
    <ContentControl VerticalAlignment="Bottom" Grid.Column="1" Grid.Row="2"
                    Template="{StaticResource WhiteScoreTemplate}"/>
    <ContentControl Grid.Row="3" Grid.ColumnSpan="2" HorizontalAlignment="Center"
                    Template="{StaticResource RestartGameTemplate}"/>
  </Grid>

  <!-- Filled layout -->
  <Grid x:Name="FilledLayout"
        common:GridUtils.ColumnDefinitions="3*,*,*"
        common:GridUtils.RowDefinitions="*,0.4*,*">
    <ContentControl VerticalAlignment="Bottom" Grid.Column="1" Grid.Row="1"
                    Template="{StaticResource BlackScoreTemplate}"/>
    <ContentControl Grid.Column="1" Grid.ColumnSpan="2"
                    Template="{StaticResource ReversiTitleTemplate}"/>
    <ContentControl Grid.Column="1" Grid.ColumnSpan="2" Grid.Row="2"
                    Template="{StaticResource RestartGameTemplate}"/>
    <ContentControl Grid.Column="0" Grid.RowSpan="3"
                    Template="{StaticResource ReversiBoardTemplate}"/>
    <ContentControl Grid.Column="2" Grid.Row="1"
                    Template="{StaticResource WhiteScoreTemplate}"/>
  </Grid>

  <!-- Landscape layout -->
  <Grid x:Name="LandscapeLayout"
        common:GridUtils.ColumnDefinitions="Auto,*,*"
        common:GridUtils.RowDefinitions="*,0.5*,*">
    <ContentControl VerticalAlignment="Bottom" Grid.Column="1" Grid.Row="1"
                    Template="{StaticResource BlackScoreTemplate}"/>
    <ContentControl Grid.Column="1" Grid.ColumnSpan="2"
                    Template="{StaticResource ReversiTitleTemplate}"/>
    <ContentControl Grid.Column="1" Grid.ColumnSpan="2" Grid.Row="2"
                    Template="{StaticResource RestartGameTemplate}"/>
    <ContentControl Grid.Column="0" Grid.RowSpan="3"
                    Template="{StaticResource ReversiBoardTemplate}"/>
    <ContentControl VerticalAlignment="Bottom" Grid.Column="2" Grid.Row="1"
                    Template="{StaticResource WhiteScoreTemplate}"/>
  </Grid>
</Grid> 

如上代码所示,应用程序有三种不同的布局,VisualStateManager 根据当前的应用程序视图状态显示/隐藏每个布局。ContentControls 的使用确保了视图组件的完全重用,它还允许您清晰地看到各种布局。

使用 Visual Studio,您可以通过设备面板测试各种状态。这是游戏在横向模式下的样子。

这是游戏在纵向模式下的样子,您可以看到应用了不同的布局。

同样建议针对所有不同的显示分辨率测试您的应用程序,这也可以通过设备面板上的“显示”属性来实现。

棋盘上的每个方格都使用 BoardSqureView 进行渲染。您可能已经注意到在 ReversiBoardTemplate Squares 属性上使用了绑定到视图模型的 ItemsControl 。这确保为棋盘上的 64 个方格中的每一个都构造一个 BoardSquareView BoardSquareView 根据其绑定的方格状态显示/隐藏黑色或白色棋子。

ItemsControl 创建一个 ContentPresenter 来承载每个 BoardSquareView 实例。正是这些 ContentPresenter 实例被添加到被指定为 ItemsPanel Grid 中。为了在 Grid 中正确排列每个方格,必须在每个 ContentPresenter 上正确设置 Grid.Row Grid.Column 。在 WPF 中,可以通过指定带有附加 Grid 属性绑定的 ItemContainerStyle 来实现这一点。不幸的是,Windows 8 应用商店不支持此功能。

作为此问题的解决方法,BoardSquareView 通过代码设置所需的绑定。

public sealed partial class BoardSquareView : UserControl
{
  public BoardSquareView()
  {
    this.InitializeComponent();

    this.LayoutUpdated += BoardSquareView_LayoutUpdated;
  }

  void BoardSquareView_LayoutUpdated(object sender, object e)
  {
    var container = VisualTreeHelper.GetParent(this) as FrameworkElement;

    container.SetBinding(Grid.RowProperty, new Binding()
    {
      Source = this.DataContext,
      Path = new PropertyPath("Row")
    });

    container.SetBinding(Grid.ColumnProperty, new Binding()
    {
      Source = this.DataContext,
      Path = new PropertyPath("Column")
    });
  }
}

这基本上涵盖了与视图相关的所有有趣点,现在是时候更仔细地研究游戏逻辑了……

翻转棋游戏逻辑

GameBoardViewModel 跟踪每个玩家的分数、谁有下一个回合以及游戏是否结束——这些都是相当简单的任务。这个视图模型执行的更复杂的任务与强制执行翻转棋游戏的逻辑有关。

当玩家点击棋盘时,这会被 BoardSquareView 渲染的 Button 捕获。此按钮绑定到 GameBoardSquareViewModel 公开的命令,该命令随后通知 GameBoardViewModel 玩家试图进行移动。所有这些都相当直接。

当玩家点击一个单元格时,会发生以下一系列事件:

  1. 检查移动是否有效(如果无效……则中止!)。
  2. 设置被点击单元格的状态。
  3. 翻转被包围的对手的棋子。
  4. 交换回合。
  5. 检查对手是否可以进行回合——如果不能,则换回。
  6. 检查游戏是否结束。
  7. 更新分数

这一切都发生在 GameBoardViewModel 公开的以下方法中。

/// <summary>
/// Makes the given move for the current player. Score are updated and play then moves
/// to the next player.
/// </summary>
public void MakeMove(int row, int col)
{
  // is this a valid move?
  if (!IsValidMove(row, col, NextMove))
    return;

  // set the square to its new state
  GetSquare(row, col).State = NextMove;

  // flip the opponents counters
  FlipOpponentsCounters(row, col, NextMove);
      
  // swap moves
  NextMove = InvertState(NextMove);

  // if this player cannot make a move, swap back again
  if (!CanPlayerMakeAMove(NextMove))
  {
    NextMove = InvertState(NextMove);
  }

  // check whether the game has finished
  GameOver = HasGameFinished();

  // update the scores
  BlackScore = _squares.Count(s => s.State == BoardSquareState.BLACK);
  WhiteScore = _squares.Count(s => s.State == BoardSquareState.WHITE);
} 

我们将在 IsValidMove 中更详细地了解视图模型如何确定移动是否有效。

在翻转棋游戏中,如果一个移动可以包围对手的一个或多个棋子(水平或对角线),则该移动有效。这意味着视图模型必须搜索 8 个不同的方向来寻找被包围的棋子。我没有写 8 次相同的逻辑,而是决定将确定棋子是否被包围的逻辑与每个方向的棋盘导航逻辑解耦。

首先,我定义了一个委托,用于更新传递给它的行和列。视图模型创建此委托的 8 个实例,每个实例代表棋盘可以导航的 8 个方向。

delegate void NavigationFunction(ref int row, ref int col);

private static List<NavigationFunction> _navigationFunctions = new List<NavigationFunction>();

static GameBoardViewModel()
{
  _navigationFunctions.Add(delegate(ref int row, ref int col) { row++; });
  _navigationFunctions.Add(delegate(ref int row, ref int col) { row--; });
  _navigationFunctions.Add(delegate(ref int row, ref int col) { row++; col--; });
  _navigationFunctions.Add(delegate(ref int row, ref int col) { row++; col++; });
  _navigationFunctions.Add(delegate(ref int row, ref int col) { row--; col--; });
  _navigationFunctions.Add(delegate(ref int row, ref int col) { row--; col++; });
  _navigationFunctions.Add(delegate(ref int row, ref int col) { col++; });
  _navigationFunctions.Add(delegate(ref int row, ref int col) { col--; });
}

视图模型使用这些导航函数来提供从特定起点在特定方向导航时遇到的方格列表。

/// <summary>
/// A list of board squares that are yielded via the given navigation function.
/// </summary>
private IEnumerable<GameBoardSquareViewModel> NavigateBoard(NavigationFunction navigationFunction,
                                                            int row, int column)
{
  navigationFunction(ref column, ref row);
  while (column >= 0 && column <= 7 && row >= 0 && row <= 7)
  {
    yield return GetSquare(row, column);
    navigationFunction(ref column, ref row);
  }
} 

这巧妙地封装了在特定方向导航棋盘的逻辑以及检查棋盘边界的需求。

IsValidMove 使用导航函数集合来确定移动是否有效,正如您从 Any Linq 方法的使用中可以看到的那样。

/// <summary>
/// Determines whether the given move is valid
/// </summary>
public bool IsValidMove(int row, int col, BoardSquareState state)
{
  // check the cell is empty
  if (GetSquare(row, col).State != BoardSquareState.EMPTY)
    return false;

  // if counters are surrounded in any direction, the move is valid
  return _navigationFunctions.Any(navFunction => MoveSurroundsCounters(row, col, navFunction, state));
}

Any 查询使用 MoveSurroundsCounter 方法,该方法提供了逻辑的“核心”。

/// <summary>
/// Determines whether the given move 'surrounds' any of the opponents pieces.
/// </summary>
private bool MoveSurroundsCounters(int row, int column,
  NavigationFunction navigationFunction, BoardSquareState state)
{
  int index = 1;

  var squares = NavigateBoard(navigationFunction, row, column);
  foreach(var square in squares)
  {
    BoardSquareState currentCellState = square.State;

    // the cell that is the immediate neighbour must be of the other colour
    if (index == 1)
    {
      if (currentCellState != InvertState(state))
      {
        return false;
      }
    }
    else
    {
      // if we have reached a cell of the same colour, this is a valid move
      if (currentCellState == state)
      {
        return true;
      }

      // if we have reached an empty cell - fail
      if (currentCellState == BoardSquareState.EMPTY)
      {
        return false;
      }
    }

    index++;
  }

  return false;
} 

在上面的代码中,您可以看到它使用 NavigateBoard 函数来遍历由导航函数决定的方向上的方格。

 

如果移动有效,则将翻转在 8 个方向上被包围的棋子。同样,这利用了导航函数。

/// <summary>
/// Flips all the opponents pieces that are surrounded by the given move.
/// </summary>
private void FlipOpponentsCounters(int row, int column, BoardSquareState state)
{
  foreach (var navigationFunction in _navigationFunctions)
  {
    // are any pieces surrounded in this direction?
    if (!MoveSurroundsCounters(row, column, navigationFunction, state))
      continue;

    BoardSquareState opponentsState = InvertState(state);

    var squares = NavigateBoard(navigationFunction, row, column);
    foreach (var square in squares)
    {
      if (square.State == state)
        break;

      square.State = state;
    }
  }
}

最后,检查游戏是否结束只是采取了粗暴的方法,即检查每一个方格,看它是否是每个玩家的有效移动。

private bool HasGameFinished()
{
    return  !CanPlayerMakeAMove(BoardSquareState.BLACK) &&
            !CanPlayerMakeAMove(BoardSquareState.WHITE);
}

/// <summary>
/// Determines whether there are any valid moves that the given player can make.
/// </summary>
private bool CanPlayerMakeAMove(BoardSquareState state)
{
    // test all the board locations to see if a move can be made
    for (int row = 0; row < 8; row++)
    {
        for (int col = 0; col < 8; col++)
        {
            if (IsValidMove(row, col, state))
            {
                return true;
            }
        }
    }
    return false;
} 

这完成了游戏逻辑!

电脑玩家 – Minimax 算法

“我恐怕,有时你也会玩孤独的游戏。你赢不了的游戏,因为你会与自己对弈。” ― 苏斯博士, 《哦,你想去的地方!》

如果您没有对手,请不要绝望,我们将为您创建一个电脑对手来让您打发时间!

代表电脑对手的类被初始化为游戏视图模型,它观察游戏视图模型以确定何时是电脑的回合。

public class ComputerOpponent
{
  private int _maxDepth;

  private GameBoardViewModel _viewModel;

  private BoardSquareState _computerColor;

  public ComputerOpponent(GameBoardViewModel viewModel, BoardSquareState computerColor, int maxDepth)
  {
    _maxDepth = maxDepth;
    _computerColor = computerColor;
    _viewModel = viewModel;
    _viewModel.PropertyChanged += GameBoardViewModel_PropertyChanged;

    MakeMoveIfCorrectTurn();
  }

  private void GameBoardViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e)
  {
    if (e.PropertyName == "NextMove")
    {
      MakeMoveIfCorrectTurn();
    }
  }
  ...
}

电脑采取了一种粗暴的方法来决定下一步棋。它分析每一个潜在的下一步棋,然后给它们打分,选择最好的一个。

private void MakeNextMove()
{
  Move bestMove = new Move()
  {
    Column = -1,
    Row = -1
  };
  int bestScore = int.MinValue;

  // check every valid move for this particular board, then select the one with the best 'score'
  List<Move> moves = ValidMovesForBoard(_viewModel);
  foreach (Move nextMove in moves)
  {
    // clone the current board and make this move
    GameBoardViewModel testBoard = new GameBoardViewModel(_viewModel);
    testBoard.MakeMove(nextMove.Row, nextMove.Column);

    // determine the score for this move
    int scoreForMove = ScoreForBoard(testBoard, 1);

    // pick the best
    if (scoreForMove > bestScore || bestScore == int.MinValue)
    {
      bestScore = scoreForMove;
      bestMove.Row = nextMove.Row;
      bestMove.Column = nextMove.Column;
    }
  }

  if (bestMove.Column != -1 && bestMove.Row != -1)
  {
    _viewModel.MakeMove(bestMove.Row, bestMove.Column);
  }
}<span style="white-space: normal;">
</span>

如上代码所示,GameBoardViewModel 有一个方便的复制构造函数,它允许电脑对手执行所需的“如果”分析。 

ScoreForBoard 方法很有意思,在深入研究它之前,我们将介绍 Minimax 算法。 

电脑可以根据每个潜在移动所产生的得分差异来选择下一步棋,并选择最好的一个。例如,如果电脑确定它可以进行三种不同的移动,那么它将选择得分最高的那个,如下所示。

 

但是,如果您查看下一个回合,对手(即您!)将试图最小化电脑试图最大化的相同分数。在下面的例子中,如果您看一下下一个回合会发生什么,情况会有所不同。

 

通过一步前瞻模型,电脑会选择得分“5”的移动,即标记为“A”的移动。然而,在下一个回合,一个熟练的人类对手将选择给电脑带来最低分数的移动,因此会选择移动“B”。

您可以看到,通过展望下一轮的移动,很容易确定“C”将是导致最佳得分的移动,并期望之后的下一个移动是“D”。

但为什么停在那里呢?电脑凭借其无限的智慧和计算能力,可以继续分析潜在移动和游戏状态的树,甚至一直分析到游戏结束!相比之下,人类竞争对手很难看到几步以上。

这里描述的方法是 Minimax 算法,其中一个玩家试图最大化他们的游戏得分,而他们的对手试图最小化这个相同的得分。

为了利用 Minimax 算法,ScoreForBoard 递归地计算每个移动的分数。

// Computes the score for the given board
private int ScoreForBoard(GameBoardViewModel board, int depth)
{
  // if we have reached the maximum search depth, then just compute the score of the current
  // board state
  if (depth >= _maxDepth)
  {
    return _computerColor == BoardSquareState.WHITE ?
                              board.WhiteScore - board.BlackScore :
                              board.BlackScore - board.WhiteScore;
  }

  int minMax = int.MinValue;

  // check every valid next move for this particular board
  List<Move> moves = ValidMovesForBoard(board);
  foreach (Move nextMove in moves)
  {
    // clone the current board and make the move
    GameBoardViewModel testBoard = new GameBoardViewModel(board);
    testBoard.MakeMove(nextMove.Row, nextMove.Column);

    // compute the score for this board
    int score = ScoreForBoard(testBoard, depth + 1);

    // pick the best score
    if (depth % 2 == 0)
    {
      if (score > minMax || minMax == int.MinValue)
      {
        minMax = score;
      }
    }
    else
    {
      if (score < minMax || minMax == int.MinValue)
      {
        minMax = score;
      }
    }
  }

  return minMax;
}

您可以在上面的代码中看到,计算分数的算法在 depth%2==0 时(即在交替回合中)被反转。这反映了之前的描述,即一个玩家正在最大化得分,而另一个玩家则在最小化相同的值。

让电脑投入运行就像创建这个类的实例一样简单。

var vm = new GameBoardViewModel();
var comp = new ComputerOpponent(vm, BoardSquareState.WHITE, 5);
DataContext = vm; 

当然,搜索深度越大,电脑玩家就会越聪明,但它也会变慢。

为了好玩,为什么不创建两个电脑玩家,然后看着它自己玩呢? 

结论

希望您喜欢本教程,并且可能学到了一些新东西。这个应用程序还有很多可以改进的地方,不妨尝试以下操作:

  • 添加一个视觉指示器,显示下一个回合是谁的。
  • 添加一个指示器,突出显示代表下一个回合有效移动的方格。
  • 添加一个记分牌。 

完整的项目源代码,包括原始 GIMP 图形,可在 GitHub 上获取。 

 

© . All rights reserved.