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

WPF 的纸牌接龙和蜘蛛纸牌

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (89投票s)

2011年9月9日

CPOL

24分钟阅读

viewsIcon

184220

downloadIcon

15627

逐步创建 WPF 纸牌和蜘蛛纸牌。

目录

  1. 引言 
  2. 背景
  3. 逐步教程 
  4. 纸牌和增强现实 
  5. 最终想法  

简介 

在本文中,我将向您展示如何使用 WPF 创建经典的纸牌和蜘蛛纸牌游戏。我将这些游戏紧密基于 Windows Vista 及更高版本中的版本。首先,是一些屏幕截图

克朗代克纸牌

solitaire/Klondike.jpg

蜘蛛纸牌

solitaire/Spider.jpg

赌场 (主页)

solitaire/Casino.jpg

背景

我一直想写这个,但它已经变成了一个“永远没有完成”的项目。我还有一些东西很想加进去,但我认为现在是时候为这个项目画上句号了——我在文章末尾列出了一个“期望功能”列表,所以如果有人想贡献,请尽管去做!

逐步教程

我将逐步讲解整个项目,所以我将从头开始构建一个全新的项目并带领您完成它。但是,一些可能重复或通用的部分将被略过——如果有人觉得有什么遗漏,请评论,我将进行详细说明。

步骤 1:构建项目

创建一个名为 Solitaire 的新 WPF 应用程序(我将目标设置为 .NET 4,我建议您也这样做,这样我编写的代码就可以为您工作)。

立即向解决方案添加一个名为“SolitaireGames”的新 WPF 用户控件库。我们将在其中放置纸牌游戏代码和托管它的控件,我们将其保存在一个单独的库中,以防我们将来想将其添加到另一个项目。在项目“Solitaire”中,添加对项目“SolitaireGames”的引用。在 SolitaireGames 中,删除“UserControl1.xaml”,我们不需要它。

我们将在这个项目中使用 MVVM 设计模式,我正在使用我自己的轻量级库 Apex。我在文章顶部包含了可分发的 Apex.dll,这两个项目都需要将其作为依赖项。现在我们可以开始了。

步骤 2:创建核心类

好吧,我们需要类和枚举来表示扑克牌。让我们将它们一个接一个地添加到 SolitaireGames 项目中。首先,创建一个名为 CardColor.cs 的文件。

namespace SolitaireGames
{
    /// <summary>
    /// Represents a card colour.
    /// </summary>
    public enum CardColour
    {
        /// <summary>
        /// Black cards (spades or clubs).
        /// </summary>
        Black,

        /// <summary>
        /// Red cards (hearts or diamonds). 
        /// </summary>
        Red
    }
}

甚至没有 using 指令,非常简单。

现在创建 CardSuit.cs

namespace SolitaireGames
{
    /// <summary>
    /// Represents a card's suit.
    /// </summary>
    public enum CardSuit
    {
        /// <summary>
        /// Hearts. 
        /// </summary>
        Hearts,

        /// <summary>
        /// Diamonds.
        /// </summary>
        Diamonds,

        /// <summary>
        /// Clubs.
        /// </summary>
        Clubs,

        /// <summary>
        /// Spades.
        /// </summary>
        Spades
    }
}

到目前为止一切顺利,最后我们需要最重要的枚举——CardType 枚举。添加 CardType.cs

namespace SolitaireGames
{
    /// <summary>
    /// The Card Types.
    /// </summary>
    public enum CardType
    {
        //  Hearts
        HA,
        H2,
        H3,
        H4,
        H5,
        H6,
        H7,
        H8,
        H9,
        H10,
        HJ,
        HQ,
        HK,

        //  Diamonds
        DA,
        D2,
        D3,
        D4,
        D5,
        D6,
        D7,
        D8,
        D9,
        D10,
        DJ,
        DQ,
        DK,

        //  Clubs
        CA,
        C2,
        C3,
        C4,
        C5,
        C6,
        C7,
        C8,
        C9,
        C10,
        CJ,
        CQ,
        CK,

        //  Spades
        SA,
        S2,
        S3,
        S4,
        S5,
        S6,
        S7,
        S8,
        S9,
        S10,
        SJ,
        SQ,
        SK
    }
}

好的,对于这个,我跳过了“注释每个枚举成员”的规则,我们现在真的要疯狂了。

我们要添加的下一个文件是 PlayingCard.cs,对于这个文件,我们将一点一点地讲解。

using Apex.MVVM;

namespace SolitaireGames
{
    /// <summary>
    /// The Playing Card represents a Card played in a game - so as
    /// well as the card type it also has the face down property etc.
    /// </summary>
    public class PlayingCard : ViewModel
    {
        /// <summary>
        /// Gets the card suit.
        /// </summary> 
        /// <value>The card suit.</value>
        public CardSuit Suit
        {
            get
            {
                //  The suit can be worked out from
                //  the numeric value of the CardType enum.
                int enumVal = (int)CardType;
                if (enumVal < 13)
                    return CardSuit.Hearts;
                if (enumVal < 26)
                    return CardSuit.Diamonds;
                if(enumVal < 39)
                    return CardSuit.Clubs;
                return CardSuit.Spades;
            }
        }

PlayingCard 类是一个 ViewModel,如 Apex 文章中所述。它所做的只是让我们访问 NotifyingProperty 构造,它处理 ViewModel 类中的所有 INotifyPropertyChanged 内容;我们稍后会看到更多。

什么是扑克牌?嗯,在这个上下文中,扑克牌不仅仅是面值,它是一张被“玩”的牌,也就是说,我们不仅知道它的价值,还知道它是面朝下等等。第一个属性只是一个小的辅助属性,它获取花色——基于牌型(这是一个稍后定义的属性!)的数值。

/// <summary>
/// Gets the card value.
/// </summary>
/// <value>The card value.</value>
public int Value
{
    get 
    {
        //  The CardType enum has 13 cards in each suit.
        return ((int)CardType) % 13;
    }
}

/// <summary>
/// Gets the card colour.
/// </summary>
/// <value>The card colour.</value>
public CardColour Colour
{
    get 
    {
        // The first two suits in the CardType
        // enum are red, the last two are black.
        return ((int)CardType) < 26 ? 
                 CardColour.Red : CardColour.Black;
    }
}

牌值是另一个辅助属性,当我们想查看一张牌是否比另一张牌高等等时很有用。我们还有牌的颜色属性——在比较牌时再次有用。到目前为止,这些都是只读属性——它们只是辅助属性,都基于 CardType,接下来就是它。

/// <summary>
/// The card type notifying property.
/// </summary>
private NotifyingProperty CardTypeProperty =
        new NotifyingProperty("CardType", typeof(CardType), CardType.SA);

/// <summary>
/// Gets or sets the type of the card.
/// </summary>
/// <value>The type of the card.</value>
public CardType CardType
{
    get { return (CardType)GetValue(CardTypeProperty); }
    set { SetValue(CardTypeProperty, value); }
}

好的,这可能看起来有点陌生。使用 Apex,我们定义了 NotifyingProperty——它们看起来与依赖属性非常相似,但会自动处理调用 NotifyPropertyChanged——因此此类的所有重要成员都将是通知属性,并且当它们的值更改时,绑定到它们的任何内容都将知道。

接下来的四个属性是 IsFaceDown(牌在游戏中是否面朝下)、IsPlayable(用户是否可以移动这张牌),以及 FaceDownOffset/FaceUpOffset(这些将在牌布局时偶尔使用)。它们在这里,这结束了 PlayingCard.cs 文件

/// <summary>
/// The IsFaceDown notifying property.
/// </summary>
private NotifyingProperty IsFaceDownProperty =
  new NotifyingProperty("IsFaceDown", typeof(bool), false);

/// <summary>
/// Gets or sets a value indicating whether this instance is face down.
/// </summary>
/// <value>
///     <c>true</c> if this instance is face down;
///     otherwise, <c>false</c>.
/// </value>
public bool IsFaceDown
{
    get { return (bool)GetValue(IsFaceDownProperty); }
    set { SetValue(IsFaceDownProperty, value); }
}

/// <summary>
/// The IsPlayable notifying property.
/// </summary>
private NotifyingProperty IsPlayableProperty =
  new NotifyingProperty("IsPlayable", typeof(bool), false);

/// <summary>
/// Gets or sets a value indicating whether this instance is playable.
/// </summary>
/// <value>
///     <c>true</c> if this instance
///    is playable; otherwise, <c>false</c>.
/// </value>
public bool IsPlayable
{
    get { return (bool)GetValue(IsPlayableProperty); }
    set { SetValue(IsPlayableProperty, value); }
}

/// <summary>
/// The FaceDown offset property.
/// </summary>
private NotifyingProperty FaceDownOffsetProperty =
  new NotifyingProperty("FaceDownOffset", typeof(double), default(double));

/// <summary>
/// Gets or sets the face down offset.
/// </summary>
/// <value>The face down offset.</value>
public double FaceDownOffset
{
    get { return (double)GetValue(FaceDownOffsetProperty); }
    set { SetValue(FaceDownOffsetProperty, value); }
}

/// <summary>
/// The FaceUp offset property.
/// </summary>
private NotifyingProperty FaceUpOffsetProperty =
        new NotifyingProperty("FaceUpOffset", 
        typeof(double), default(double));

/// <summary>
/// Gets or sets the face up offset.
/// </summary>
/// <value>The face up offset.</value>
public double FaceUpOffset
{
    get { return (double)GetValue(FaceUpOffsetProperty); }
    set { SetValue(FaceUpOffsetProperty, value); }
}

这里有点超前思维。如果您熟悉 Windows Phone 7 的 pivot 控件,这大致就是我们将如何展示这个应用程序。会有一个 pivot 控件,包含四个项目——克朗代克纸牌(也就是 Windows 中的标准纸牌),“赌场”(我们可以在其中查看统计数据并进入任何其他游戏),蜘蛛纸牌和设置。

由于我们有多个纸牌游戏,我们将为纸牌游戏的 ViewModel 创建一个公共基类。创建一个名为 CardGameViewModel.cs 的文件

using System;
using System.Collections.Generic;
using Apex.MVVM;
using System.Windows.Threading;

namespace SolitaireGames
{
    /// <summary>
    /// Base class for a ViewModel for a card game.
    /// </summary>
    public class CardGameViewModel : ViewModel
    {

我通常不会把属性和成员放在前面,但这样做会更容易理解。对于一个游戏,我们需要

  • 一个计时器来计时我们玩了多久
  • 一个分数
  • 一个已用时间
  • 一个“移动”计数器(做出的不同移动次数)
  • 一个标志,指示游戏已获胜
  • 一个在游戏获胜时触发的事件
  • 几个命令——去赌场,点击牌,以及开始一个新游戏

命令由 Apex 通过 ViewModelCommand 类处理,我们稍后会看到更多。无论如何,以下是纸牌游戏所需的属性和成员。

/// <summary>
/// The timer for recording the time spent in a game.
/// </summary>
private DispatcherTimer timer = new DispatcherTimer();

/// <summary>
/// The time of the last tick.
/// </summary>
private DateTime lastTick;

/// <summary>
/// The score notifying property.
/// </summary>
private NotifyingProperty scoreProperty = 
        new NotifyingProperty("Score", typeof(int), 0);

/// <summary>
/// Gets or sets the score.
/// </summary>
/// <value>The score.</value>
public int Score
{
    get { return (int)GetValue(scoreProperty); }
    set { SetValue(scoreProperty, value); }
}

/// <summary>
/// The elapsed time property.
/// </summary>
private readonly NotifyingProperty elapsedTimeProperty =
    new NotifyingProperty("ElapsedTime", 
    typeof(double), default(double));

/// <summary>
/// Gets or sets the elapsed time.
/// </summary>
/// <value>The elapsed time.</value>
public TimeSpan ElapsedTime
{
    get { return TimeSpan.FromSeconds(
         (double)GetValue(elapsedTimeProperty)); }
    set { SetValue(elapsedTimeProperty, value.TotalSeconds); }
}

/// <summary>
/// The moves notifying property.
/// </summary>
private readonly NotifyingProperty movesProperty =
    new NotifyingProperty("Moves", typeof(int), 0);

/// <summary>
/// Gets or sets the moves.
/// </summary>
/// <value>The moves.</value>
public int Moves
{
    get { return (int)GetValue(movesProperty); }
    set { SetValue(movesProperty, value); }
}

/// <summary>
/// The victory flag.
/// </summary>
private NotifyingProperty isGameWonProperty = 
        new NotifyingProperty("IsGameWon", typeof(bool), false);

/// <summary>
/// Gets or sets a value indicating whether this instance is game won.
/// </summary>
/// <value>
///     <c>true</c> if this instance
///           is game won; otherwise, <c>false</c>.
/// </value>
public bool IsGameWon
{
    get { return (bool)GetValue(isGameWonProperty); }
    set { SetValue(isGameWonProperty, value); }
}

/// <summary>
/// The left click card command.
/// </summary>
private ViewModelCommand leftClickCardCommand;

/// <summary>
/// Gets the left click card command.
/// </summary>
/// <value>The left click card command.</value>
public ViewModelCommand LeftClickCardCommand
{
    get { return leftClickCardCommand; }
}

/// <summary>
/// The right click card command.
/// </summary>
private ViewModelCommand rightClickCardCommand;

/// <summary>
/// Gets the right click card command.
/// </summary>
/// <value>The right click card command.</value>
public ViewModelCommand RightClickCardCommand
{
    get { return rightClickCardCommand; }
}

/// <summary>
/// The command to go to the casino.
/// </summary>
private ViewModelCommand goToCasinoCommand;

/// <summary>
/// Gets the go to casino command.
/// </summary>
/// <value>The go to casino command.</value>
public ViewModelCommand GoToCasinoCommand
{
    get { return goToCasinoCommand; }
}

/// <summary>
/// The command to deal a new game.
/// </summary>
private ViewModelCommand dealNewGameCommand;

/// <summary>
/// Gets the deal new game command.
/// </summary>
/// <value>The deal new game command.</value>
public ViewModelCommand DealNewGameCommand
{
    get { return dealNewGameCommand; }
}

/// <summary>
/// Occurs when the game is won.
/// </summary>
public event Action GameWon;

现在您应该熟悉 NotifyingProperty 了,而 ViewModelCommand 是一个非常标准的 MVVM 命令对象,更多信息请参阅 Apex 文章。

好的,这个类的主要部分就是上面我们所说的,让我们完成它。

/// <summary>
/// Initializes a new instance of the
/// <see cref="CardGameViewModel"> class./>
/// </summary>
public CardGameViewModel()
{
    //  Set up the timer.
    timer.Interval = TimeSpan.FromMilliseconds(500);
    timer.Tick += new EventHandler(timer_Tick);

    //  Create the commands.
    leftClickCardCommand = new ViewModelCommand(DoLeftClickCard, true);
    rightClickCardCommand = new ViewModelCommand(DoRightClickCard, true);
    dealNewGameCommand = new ViewModelCommand(DoDealNewGame, true);
    goToCasinoCommand = new ViewModelCommand(DoGoToCasino, true);
}

/// <summary>
/// The go to casino command.
/// </summary>
private void DoGoToCasino()
{
}

/// <summary>
/// The left click card command.
/// </summary>
/// <param name="parameter">The parameter.</param>
protected virtual void DoLeftClickCard(object parameter)
{
}

/// <summary>
/// The right click card command.
/// </summary>
/// <param name="parameter">The parameter.</param>
protected virtual void DoRightClickCard(object parameter)
{
}

/// <summary>
/// Deals a new game.
/// </summary>
/// <param name="parameter">The parameter.</param>
protected virtual void DoDealNewGame(object parameter)
{
    //  Stop the timer and reset the game data.
    StopTimer();
    ElapsedTime = TimeSpan.FromSeconds(0);
    Moves = 0;
    Score = 0;
    IsGameWon = false;
}

构造函数设置计时器并创建 ViewModelCommand。请注意,其中三个命令什么都不做,**但它们必须存在**——它们是 protected 的,并且派生类可能需要对它们进行特殊处理。DoDealNewGame 稍后会变得有趣,但目前,它只是重置所有内容。这个类几乎完成了——我们只是提供了一种启动和停止计时器的方法,以及一种触发 GameWon 事件的简单方法——记住事件不能直接从派生类调用!

/// <summary>
/// Starts the timer.
/// </summary>
public void StartTimer()
{
    lastTick = DateTime.Now;
    timer.Start();
}

/// <summary>
/// Stops the timer.
/// </summary>
public void StopTimer()
{
    timer.Stop();
}

/// <summary>
/// Handles the Tick event of the timer control.
/// </summary>
/// <param name="sender">The source of the event.</param>
/// <param name="e">The <see
///     cref="System.EventArgs"> instance
///     containing the event data.</param>
private void timer_Tick(object sender, EventArgs e)
{
    //  Get the time, update the elapsed time, record the last tick.
    DateTime timeNow = DateTime.Now;
    ElapsedTime += timeNow - lastTick;
    lastTick = timeNow;
}

/// <summary>
/// Fires the game won event.
/// </summary>
protected void FireGameWonEvent()
{
    Action wonEvent = GameWon;
    if (wonEvent != null)
        wonEvent();
}

好的,我们已经花了很多时间在核心类上,现在是时候真正开始制作游戏了。

步骤 3:克朗代克纸牌 - 逻辑

克朗代克纸牌是我们熟悉的 Windows 中的“纸牌”朋友。我们在这个项目中将其命名为 KlondikeSolitaire,以区别于蜘蛛纸牌或其他任何我们有的纸牌。在 SolitaireGames 项目中添加一个名为 KlondikeSolitaire 的文件夹。

创建一个名为 KlondikeSolitaireViewModel 的类,它将包含游戏的所有逻辑。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Apex.MVVM;
using Apex.Extensions;
using System.Collections.ObjectModel;

namespace SolitaireGames.KlondikeSolitaire
{
    /// <summary>
    /// The DrawMode, i.e. how many cards to draw.
    /// </summary>
    public enum DrawMode
    {
        /// <summary>
        /// Draw one card.
        /// </summary>
        DrawOne = 0,

        /// <summary>
        /// Draw three cards.
        /// </summary>
        DrawThree = 1
    }

    /// <summary>
    /// The Klondike Solitaire View Model.
    /// </summary>
    public class KlondikeSolitaireViewModel : CardGameViewModel
    {

文件顶部有一个有用的小枚举 DrawMode - 它定义了我们是抽一张牌还是三张牌。同样,我更喜欢将成员变量和属性放在末尾,但为了便于描述,我将按以下顺序写出它们。

//  For ease of access we have arrays of the foundations and tableaus.
List<observablecollection<playingcard>> foundations = 
       new List<observablecollection<playingcard>>();
List<observablecollection<playingcard>> tableaus = 
       new List<observablecollection<playingcard>>();

//  Klondike Solitaire has four foundations.
private ObservableCollection<playingcard> foundation1 = 
        new ObservableCollection<playingcard>();
private ObservableCollection<playingcard> foundation2 = 
        new ObservableCollection<playingcard>();
private ObservableCollection<playingcard> foundation3 = 
        new ObservableCollection<playingcard>();
private ObservableCollection<playingcard> foundation4 = 
        new ObservableCollection<playingcard>();

//  It also has seven tableaus.
private ObservableCollection<playingcard> tableau1 = 
        new ObservableCollection<playingcard>();
private ObservableCollection<playingcard> tableau2 = 
        new ObservableCollection<playingcard>();
private ObservableCollection<playingcard> tableau3 = 
        new ObservableCollection<playingcard>();
private ObservableCollection<playingcard> tableau4 = 
        new ObservableCollection<playingcard>();
private ObservableCollection<playingcard> tableau5 = 
        new ObservableCollection<playingcard>();
private ObservableCollection<playingcard> tableau6 = 
        new ObservableCollection<playingcard>();
private ObservableCollection<playingcard> tableau7 = 
        new ObservableCollection<playingcard>();

//  As in most games there is one stock pile.
private ObservableCollection<playingcard> stock = 
        new ObservableCollection<playingcard>();

//  Also, there is the waste pile...
private ObservableCollection<playingcard> waste = 
        new ObservableCollection<playingcard>();

一些术语:一个“Foundation”是我们构建一套同花牌的地方,是纸牌游戏中最上面的四叠牌。我们有四个这样的牌堆,它们是可观察的集合。一个“Tableau”是一组牌,有些面朝上或面朝下,我们大部分工作都在这里进行,我们根据游戏规则在 Tableau 之间移动牌。克朗代克纸牌有七个 Tableau,每个 Tableau 开始时有一到七张牌。“Stock”是我们可以“翻”到“Waste”的面朝下的牌。“Waste”是我们可以从中抽牌的小牌堆。我们还需要一个列表来保存每个 Tableau 和 Foundation,这在以后会派上用场。以下是最后几个属性。

/// <summary>
/// The draw mode property.
/// </summary>
private NotifyingProperty DrawModeProperty =
  new NotifyingProperty("DrawMode", typeof(DrawMode), 
                        DrawMode.DrawThree);

/// <summary>
/// Gets or sets the draw mode.
/// </summary>
/// <value>The draw mode.</value>
public DrawMode DrawMode
{
    get { return (DrawMode)GetValue(DrawModeProperty); }
    set { SetValue(DrawModeProperty, value); }
}

//  Accessors for the various card stacks.
public ObservableCollection<playingcard> Foundation1 { get { return foundation1; } }
public ObservableCollection<playingcard> Foundation2 { get { return foundation2; } }
public ObservableCollection<playingcard> Foundation3 { get { return foundation3; } }
public ObservableCollection<playingcard> Foundation4 { get { return foundation4; } }
public ObservableCollection<playingcard> Tableau1 { get { return tableau1; } }
public ObservableCollection<playingcard> Tableau2 { get { return tableau2; } }
public ObservableCollection<playingcard> Tableau3 { get { return tableau3; } }
public ObservableCollection<playingcard> Tableau4 { get { return tableau4; } }
public ObservableCollection<playingcard> Tableau5 { get { return tableau5; } }
public ObservableCollection<playingcard> Tableau6 { get { return tableau6; } }
public ObservableCollection<playingcard> Tableau7 { get { return tableau7; } }
public ObservableCollection<playingcard> Stock { get { return stock; } }
public ObservableCollection<playingcard> Waste { get { return waste; } }

/// <summary>
/// The turn stock command.
/// </summary>
private ViewModelCommand turnStockCommand;

/// <summary>
/// Gets the turn stock command.
/// </summary>
/// <value>The turn stock command.</value>
public ViewModelCommand TurnStockCommand
{
    get { return turnStockCommand; }
}

现在我们有了熟悉的抽牌模式通知属性、各种牌堆的访问器,最后是一个命令——“翻牌堆”,它将牌从牌堆移动到废牌堆。

现在我们可以添加功能了。

/// <summary>
/// Initializes a new instance of the
/// <see cref="KlondikeSolitaireViewModel"> class.
/// </summary>
public KlondikeSolitaireViewModel()
{
    //  Create the quick access arrays.
    foundations.Add(foundation1);
    foundations.Add(foundation2);
    foundations.Add(foundation3);
    foundations.Add(foundation4);
    tableaus.Add(tableau1);
    tableaus.Add(tableau2);
    tableaus.Add(tableau3);
    tableaus.Add(tableau4);
    tableaus.Add(tableau5);
    tableaus.Add(tableau6);
    tableaus.Add(tableau7);

    //  Create the turn stock command.
    turnStockCommand = new ViewModelCommand(DoTurnStock, true);

    //  If we're in the designer deal a game.
    if (Apex.Design.DesignTime.IsDesignTime)
        DoDealNewGame(null);
}


/// <summary>
/// Gets the card collection for the specified card.
/// </summary>
/// <param name="card">The card.</param>
/// <returns>
public IList<playingcard> GetCardCollection(PlayingCard card)
{
    if (stock.Contains(card)) return stock;
    if (waste.Contains(card)) return waste;
    foreach (var foundation in foundations) 
             if (foundation.Contains(card)) return foundation;
    foreach (var tableau in tableaus) 
             if (tableau.Contains(card)) return tableau;

    return null;
}

构造函数将“Foundation”和“Tableau”添加到主列表,连接“翻牌堆”命令,并且(如果我们在设计器中)实际发牌(这在以后会很有用,在设计视图中,我们将有一个新发的游戏来查看)。我们还有一个辅助函数来获取任何牌的父集合(视图稍后会需要它)。

现在我们有了第一大块严肃的逻辑

/// <summary>
/// Deals a new game.
/// </summary>
/// <param name="parameter">The parameter.</param>
protected override void DoDealNewGame(object parameter)
{
    //  Call the base, which stops the timer, clears
    //  the score etc.
    base.DoDealNewGame(parameter);

    //  Clear everything.
    stock.Clear();
    waste.Clear();
    foreach (var tableau in tableaus)
        tableau.Clear();
    foreach (var foundation in foundations)
        foundation.Clear();

    //  Create a list of card types.
    List<cardtype> eachCardType = new List<cardtype>();
    foreach (CardType cardType in Enum.GetValues(typeof(CardType)))
        eachCardType.Add(cardType);

    //  Create a playing card from each card type.
    List<playingcard> playingCards = new List<playingcard>();
    foreach (var cardType in eachCardType)
        playingCards.Add(new PlayingCard() 
        { CardType = cardType, IsFaceDown = true });

    //  Shuffle the playing cards.
    playingCards.Shuffle();

    //  Now distribute them - do the tableaus first.
    for (int i = 0; i < 7; i++)
    {
        //  We have i face down cards and 1 face up card.
        for (int j = 0; j < i; j++)
        {
            PlayingCard faceDownCard = playingCards.First();
            playingCards.Remove(faceDownCard);
            faceDownCard.IsFaceDown = true;
            tableaus[i].Add(faceDownCard);
        }

        //  Add the face up card.
        PlayingCard faceUpCard = playingCards.First();
        playingCards.Remove(faceUpCard);
        faceUpCard.IsFaceDown = false;
        faceUpCard.IsPlayable = true;
        tableaus[i].Add(faceUpCard);
    }

    //  Finally we add every card that's left over to the stock.
    foreach (var playingCard in playingCards)
    {
        playingCard.IsFaceDown = true;
        playingCard.IsPlayable = false;
        stock.Add(playingCard);
    }
    playingCards.Clear();

    //  And we're done.
    StartTimer();
}

我希望我的注释足够详细,让您能够理解它。它基本上设置了新发的牌局,排列牌。请注意,我们会在适当的时候设置 IsPlayable——这将在以后用于确保我们只能拖动应该能够拖动的牌。

视图模型逻辑中的主要“命令”是“翻牌堆”命令,它将从牌堆中翻一张或三张牌到废牌堆,或者清空废牌堆。

/// <summary>
/// Turns cards from the stock into the waste.
/// </summary>
private void DoTurnStock()
{
    //  If the stock is empty, put every
    //  card from the waste back into the stock.
    if (stock.Count == 0)
    {
        foreach (var card in waste)
        {
            card.IsFaceDown = true;
            card.IsPlayable = false;
            stock.Insert(0, card);
        }
        waste.Clear();
    }
    else
    {
        //  Everything in the waste so far must now have no offset.
        foreach (var wasteCard in waste)
            wasteCard.FaceUpOffset = 0;

        //  Work out how many cards to draw.
        int cardsToDraw = 0;
        switch (DrawMode)
        {
            case DrawMode.DrawOne:
                cardsToDraw = 1;
                break;
            case DrawMode.DrawThree:
                cardsToDraw = 3;
                break;
            default:
                cardsToDraw = 1;
                break;
        }

        //  Put up to three cards in the waste.
        for (int i = 0; i < cardsToDraw; i++)
        {
            if (stock.Count > 0)
            {
                PlayingCard card = stock.Last();
                stock.Remove(card);
                card.IsFaceDown = false;
                card.IsPlayable = false;
                card.FaceUpOffset = 30;
                waste.Add(card);
            }
        }
    }

    //  Everything in the waste must be not playable,
    //  apart from the top card.
    foreach (var wasteCard in waste)
        wasteCard.IsPlayable = wasteCard == waste.Last();
}

在克朗代克中,我们可以自动将一张牌移动到合适的基牌堆;我们需要一个函数来处理这个问题(这在我们右键点击一张牌时发生)。

/// <summary>
/// Tries the move the card to its appropriate foundation.
/// </summary>
/// <param name="card">The card.</param>
/// <returns>True if card moved.</returns>
public bool TryMoveCardToAppropriateFoundation(PlayingCard card)
{
    //  Try the top of the waste first.
    if (waste.LastOrDefault() == card)
        foreach (var foundation in foundations)
            if (MoveCard(waste, foundation, card, false))
                return true;

    //  Is the card in a tableau?
    bool inTableau = false;
    int i = 0;
    for (; i < tableaus.Count && inTableau == false; i++)
        inTableau = tableaus[i].Contains(card);

    //  It's if its not in a tablea and it's not the top
    //  of the waste, we cannot move it.
    if (inTableau == false)
        return false;

    //  Try and move to each foundation.
    foreach (var foundation in foundations)
        if (MoveCard(tableaus[i - 1], foundation, card, false))
            return true;

    //  We couldn't move the card.
    return false;
}

如果我们在某个空白区域右键单击,它将尝试将**所有**牌移动到其对应的“Foundation”,所以让我们为此编写一个函数。

/// <summary>
/// Tries the move all cards to appropriate foundations.
/// </summary>
public void TryMoveAllCardsToAppropriateFoundations()
{
    //  Go through the top card in each tableau - keeping
    //  track of whether we moved one.
    bool keepTrying = true;
    while (keepTrying)
    {
        bool movedACard = false;
        if (waste.Count > 0)
            if (TryMoveCardToAppropriateFoundation(waste.Last()))
                movedACard = true;
        foreach (var tableau in tableaus)
        {
            if (tableau.Count > 0)
                if (TryMoveCardToAppropriateFoundation(tableau.Last()))
                    movedACard = true;
        }

        //  We'll keep trying if we moved a card.
        keepTrying = movedACard;
    }
}

完美。现在我们有一个从基类 CardGameViewModel 继承的函数,当右键单击一张牌时调用该函数,我们可以使用它来调用“TryMoveCard...”函数。

/// <summary>
/// The right click card command.
/// </summary>
/// <param name="parameter">The parameter
/// (should be a playing card).</param>
protected override void DoRightClickCard(object parameter)
{
    base.DoRightClickCard(parameter);

    //  Cast the card.
    PlayingCard card = parameter as PlayingCard;
    if (card == null)
        return;

    //  Try and move it to the appropriate foundation.
    TryMoveCardToAppropriateFoundation(card);
}

我们还需要知道是否赢了——所以让我们添加一个函数来检查是否赢了,如果赢了就设置 IsGameWon 标志。

/// <summary>
/// Checks for victory.
/// </summary>
public void CheckForVictory()
{
    //  We've won if every foundation is full.
    foreach (var foundation in foundations)
        if (foundation.Count < 13)
            return;

    //  We've won.
    IsGameWon = true;

    //  Stop the timer.
    StopTimer();

    //  Fire the won event.
    FireGameWonEvent();
}

稍后我们将连接视图,以便我们可以将牌从一个牌堆拖到另一个牌堆。当这种情况发生时,我们需要知道移动是否有效,以及它是否实际执行移动并更新分数。这是该类中最复杂的函数,请仔细阅读注释。

/// <summary>
/// Moves the card.
/// </summary>
/// <param name="from">The set we're moving from.</param>
/// <param name="to">The set we're moving to.</param>
/// <param name="card">The card we're moving.</param>
/// <param name="checkOnly">if set to <c>true</c>
/// we only check if we CAN move, but don't actually move.</param>
/// <returns>True if a card was moved.</returns>
public bool MoveCard(ObservableCollection<playingcard> from,
       ObservableCollection<playingcard> to,
       PlayingCard card, bool checkOnly)
{
    //  The trivial case is where from and to are the same.
    if (from == to)
        return false;

    //  This is the complicated operation.
    int scoreModifier = 0;

    //  Are we moving from the waste?
    if (from == Waste)
    {
        //  Are we moving to a foundation?
        if (foundations.Contains(to))
        {
            //  We can move to a foundation only if:
            //  1. It is empty and we are an ace.
            //  2. It is card SN and we are suit S and Number N+1
            if ((to.Count == 0 && card.Value == 0) ||
                (to.Count > 0 && to.Last().Suit == 
                 card.Suit && (to.Last()).Value == (card.Value - 1)))
            {
                //  Move from waste to foundation.
                scoreModifier = 10;
            }
            else
                return false;
        }
        //  Are we moving to a tableau?
        else if (tableaus.Contains(to))
        {
            //  We can move to a tableau only if:
            //  1. It is empty and we are a king.
            //  2. It is card CN and we are color !C and Number N-1
            if ((to.Count == 0 && card.Value == 12) ||
                (to.Count > 0 && to.Last().Colour != card.Colour 
                  && (to.Last()).Value == (card.Value + 1)))
            {
                //  Move from waste to tableau.
                scoreModifier = 5;
            }
            else
                return false;
        }
        //  Any other move from the waste is wrong.
        else
            return false;
    }
    //  Are we moving from a tableau?
    else if (tableaus.Contains(from))
    {
        //  Are we moving to a foundation?
        if (foundations.Contains(to))
        {
            //  We can move to a foundation only if:
            //  1. It is empty and we are an ace.
            //  2. It is card SN and we are suit S and Number N+1
            if ((to.Count == 0 && card.Value == 0) ||
                (to.Count > 0 && to.Last().Suit == card.Suit 
                  && (to.Last()).Value == (card.Value - 1)))
            {
                //  Move from tableau to foundation.
                scoreModifier = 10;
            }
            else
                return false;
        }
        //  Are we moving to another tableau?
        else if (tableaus.Contains(to))
        {
            //  We can move to a tableau only if:
            //  1. It is empty and we are a king.
            //  2. It is card CN and we are color !C and Number N-1
            if ((to.Count == 0 && card.Value == 12) ||
                (to.Count > 0 && to.Last().Colour != card.Colour 
                  && (to.Last()).Value == (card.Value + 1)))
            {
                //  Move from tableau to tableau.
                scoreModifier = 0;
            }
            else
                return false;
        }
        //  Any other move from a tableau is wrong.
        else
            return false;
    }
    //  Are we moving from a foundation?
    else if (foundations.Contains(from))
    {
        //  Are we moving to a tableau?
        if (tableaus.Contains(to))
        {
            //  We can move to a tableau only if:
            //  1. It is empty and we are a king.
            //  2. It is card CN and we are color !C and Number N-1
            if ((to.Count == 0 && card.Value == 12) ||
                (to.Count > 0 && to.Last().Colour != card.Colour 
                  && (to.Last()).Value == (card.Value + 1)))
            {
                //  Move from foundation to tableau.
                scoreModifier = -15;
            }
            else
                return false;
        }
        //  Are we moving to another foundation?
        else if (foundations.Contains(to))
        {
            //  We can move from a foundation to a foundation only 
            //  if the source foundation has one card (the ace) and the
            //  destination foundation has no cards).
            if (from.Count == 1 && to.Count == 0)
            {
                //  The move is valid, but has no impact on the score.
                scoreModifier = 0;
            }
            else
                return false;
        }
        //  Any other move from a foundation is wrong.
        else
            return false;
    }
    else
        return false;

    //  If we were just checking, we're done.
    if (checkOnly)
        return true;

    //  If we've got here we've passed all tests
    //  and move the card and update the score.
    DoMoveCard(from, to, card);
    Score += scoreModifier;
    Moves++;

    //  If we have moved from the waste, we must 
    //  make sure that the top of the waste is playable.
    if (from == Waste && Waste.Count > 0)
        Waste.Last().IsPlayable = true;

    //  Check for victory.
    CheckForVictory();

    return true;
}

您可能已经注意到,在接近末尾的地方调用了一个 DoMoveCard 函数;它实际上将牌从一个地方移动到另一个地方,并且比上一个函数更直接。

/// <summary>
/// Actually moves the card.
/// </summary>
/// <param name="from">The stack to move from.</param>
/// <param name="to">The stack to move to.</param>
/// <param name="card">The card.</param>
private void DoMoveCard(ObservableCollection<playingcard> from,
    ObservableCollection<playingcard> to,
    PlayingCard card)
{
    //  Indentify the run of cards we're moving.
    List<playingcard> run = new List<playingcard>();
    for (int i = from.IndexOf(card); i < from.Count; i++)
        run.Add(from[i]);

    //  This function will move the card, as well as setting the 
    //  playable properties of the cards revealed.
    foreach(var runCard in run)
        from.Remove(runCard);
    foreach(var runCard in run)
        to.Add(runCard);

    //  Are there any cards left in the from pile?
    if (from.Count > 0)
    {
        //  Reveal the top card and make it playable.
        PlayingCard topCard = from.Last();

        topCard.IsFaceDown = false;
        topCard.IsPlayable = true;
    }
}

就是这样——该类完成了。让我们转向视觉效果。

步骤 3:克朗代克纸牌 - 视图

我们有了一个可靠的 ViewModel、通知属性和可观察集合以及命令,所以创建 View 应该会轻而易举。

KlondikeSolitaire 文件夹中添加一个新的用户控件,名为 KlondikeSolitaireView。让我们把 XAML 放在一起。

<UserControl x:Class="SolitaireGames.KlondikeSolitaire.KlondikeSolitaireView"
         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
         xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
         xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
         xmlns:local="clr-namespace:SolitaireGames.KlondikeSolitaire"
         xmlns:solitaireGames="clr-namespace:SolitaireGames"
         xmlns:apexControls="clr-namespace:Apex.Controls;assembly=Apex"
         xmlns:apexCommands="clr-namespace:Apex.Commands;assembly=Apex"
         xmlns:apexDragAndDrop="clr-namespace:Apex.DragAndDrop;assembly=Apex"
         mc:Ignorable="d" 
         x:Name="klondikeSolitaireView"
         d:DesignHeight="300" d:DesignWidth="300">
    
<!-- Point to the main resource dictionary for the assembly. -->
<UserControl.Resources>
    <ResourceDictionary 
      Source="/SolitaireGames;component/Resources/
              SolitaireGamesResourceDictionary.xaml" />
</UserControl.Resources>

用户控件将使用我们早期定义的一些不同的命名空间。我们指向一个程序集的单个资源字典——但我们稍后将详细介绍。

<!-- The main grid - the game is at the top and the commands are at the bottom. -->
<apexControls:ApexGrid 
           Rows="*,Auto"
           DataContext="{Binding ViewModel, ElementName=klondikeSolitaireView}">

<!-- The cards etc are all in a Viewbox so that they resize in a sensible
           way when we resize the window. -->
<Viewbox Grid.Row="0" Margin="10">
<!-- A DragAndDropHost allows us to perform more complicated logic when
           we drag and drop. -->
<apexDragAndDrop:DragAndDropHost 
       x:Name="dragAndDropHost" 
       MouseRightButtonDown="dragAndDropHost_MouseRightButtonDown"
       MinimumHorizontalDragDistance="0.0" 
       MinimumVerticalDragDistance="0.0">

整个控件被包裹在一个网格中(实际上是一个 ApexGrid;详情请参阅这篇文章:apexgrid.aspx),游戏在顶部,底部有一小行按钮和信息。网格的数据上下文设置为我们稍后将看到的 ViewModel 属性。

游戏在一个 ViewBox 中,这很棒,因为这意味着它在不同尺寸下看起来一致。DragAndDrop 主机是 Apex 提供的一个类,它为我们尝试移动元素(不一定是将项目从树移动到列表或其他地方)的情况提供高效且简单的拖放功能。DragAndDrop 主机详细解释起来很复杂,因此在 附录 2 中有详细说明。DragAndDropHost 中的任何内容都将适用于我们简化的拖放功能。

好了,是时候布置“Tableau”、“Foundation”等了,并将它们分别绑定到相应的“View Model”成员。

 <!-- This is the layout grid for the tableaus, foundations etc. -->
<apexControls:ApexGrid Width="1200" Height="840" Columns="*,*,*,*,*,*,*" Rows="240,600">
<!-- The drag stack, cards are stored here temporarily while they are being dragged. -->
<solitaireGames:CardStackControl 
       x:Name="dragStack" Grid.Row="0" 
       Grid.Column="0" Margin="0,-2000,0,0"
       Orientation="Vertical" 
       FaceDownOffset="10" FaceUpOffset="30"
       apexDragAndDrop:DragAndDrop.IsDragSource="False"/>
<!-- The stock. -->
<Border 
       Grid.Row="0" Grid.Column="0" 
       Style="{StaticResource StackMarker}" />
<solitaireGames:CardStackControl 
       Grid.Row="0" Grid.Column="0" 
       ItemsSource="{Binding Stock}" Cursor="Hand"
       Orientation="Horizontal" FaceDownOffset="0" 
       FaceUpOffset="0" 
       MouseLeftButtonUp="CardStackControl_MouseLeftButtonUp" />

<!-- The waste. -->
<Border 
       Grid.Row="0" Grid.Column="1" 
       Style="{StaticResource StackMarker}" />
<solitaireGames:CardStackControl 
       x:Name="wasteStack"
       Grid.Row="0" Grid.Column="1" 
       Grid.ColumnSpan="2" ItemsSource="{Binding Waste}"
       Orientation="Horizontal" OffsetMode="UseCardValues"  />

<!-- The foundations. -->
<Border 
       Grid.Row="0" Grid.Column="3" 
       Style="{StaticResource StackMarker}" />
<solitaireGames:CardStackControl 
       Grid.Row="0" Grid.Column="3" 
       ItemsSource="{Binding Foundation1}"
       Orientation="Horizontal" FaceDownOffset="0" 
       FaceUpOffset="0" />
<Border 
       Grid.Row="0" Grid.Column="4" 
       Style="{StaticResource StackMarker}" />
<solitaireGames:CardStackControl 
       Grid.Row="0" Grid.Column="4" 
       ItemsSource="{Binding Foundation2}"
       Orientation="Horizontal" 
       FaceDownOffset="0" FaceUpOffset="0" />
<Border 
       Grid.Row="0" Grid.Column="5" 
       Style="{StaticResource StackMarker}" />
<solitaireGames:CardStackControl 
       Grid.Row="0" Grid.Column="5" 
       ItemsSource="{Binding Foundation3}"
       Orientation="Horizontal" 
       FaceDownOffset="0" FaceUpOffset="0" />
<Border 
        Grid.Row="0" Grid.Column="6" 
        Style="{StaticResource StackMarker}" />
<solitaireGames:CardStackControl 
       Grid.Row="0" Grid.Column="6" 
       ItemsSource="{Binding Foundation4}"
       Orientation="Horizontal" FaceDownOffset="0" 
       FaceUpOffset="0" />


<!-- The tableaus. -->
<Border 
       Grid.Row="1" Grid.Column="0" 
       Style="{StaticResource RunMarker}" />
<solitaireGames:CardStackControl 
       Grid.Row="1" Grid.Column="0" 
       ItemsSource="{Binding Tableau1}"
       Orientation="Vertical" 
       FaceDownOffset="10" FaceUpOffset="30" />
<Border 
       Grid.Row="1" Grid.Column="1" 
       Style="{StaticResource RunMarker}" />
<solitaireGames:CardStackControl
       Grid.Row="1" Grid.Column="1" 
       ItemsSource="{Binding Tableau2}"
       Orientation="Vertical" 
       FaceDownOffset="10" FaceUpOffset="30" />
<Border 
       Grid.Row="1" Grid.Column="2" 
       Style="{StaticResource RunMarker}" />
<solitaireGames:CardStackControl 
       Grid.Row="1" Grid.Column="2" 
       ItemsSource="{Binding Tableau3}"
       Orientation="Vertical" 
       FaceDownOffset="10" FaceUpOffset="30" />
<Border 
       Grid.Row="1" Grid.Column="3" 
       Style="{StaticResource RunMarker}" />
<solitaireGames:CardStackControl 
       Grid.Row="1" Grid.Column="3" 
       ItemsSource="{Binding Tableau4}"
       Orientation="Vertical" 
       FaceDownOffset="10" FaceUpOffset="30" />
<Border 
       Grid.Row="1" Grid.Column="4" 
       Style="{StaticResource RunMarker}" />
<solitaireGames:CardStackControl 
       Grid.Row="1" Grid.Column="4" 
       ItemsSource="{Binding Tableau5}"
       Orientation="Vertical" 
       FaceDownOffset="10" FaceUpOffset="30" />
<Border 
       Grid.Row="1" Grid.Column="5" 
       Style="{StaticResource RunMarker}" />
<solitaireGames:CardStackControl 
       Grid.Row="1" Grid.Column="5" 
       ItemsSource="{Binding Tableau6}"
       Orientation="Vertical" FaceDownOffset="10" 
       FaceUpOffset="30" />
<Border 
       Grid.Row="1" Grid.Column="6" 
       Style="{StaticResource RunMarker}" />
<solitaireGames:CardStackControl 
       Grid.Row="1" Grid.Column="6" 
       ItemsSource="{Binding Tableau7}"
       Orientation="Vertical" FaceDownOffset="10" 
       FaceUpOffset="30" />
</apexControls:ApexGrid>
</apexDragAndDrop:DragAndDropHost>
</Viewbox>

我们在这里引用了一些稍后将详细说明的内容,但简而言之,我们有

  • dragStack:一个“隐藏”的牌堆,用于存放被拖动的牌堆。
  • CardStackControl:一个项目控件,可以以堆叠方式布局其子项,并具有各种不同的偏移方式。请参阅 附录 1
  • StackMarker:用于绘制牌堆模糊白色边框的资源。
  • RunMarker:用于绘制一组逐渐消失的白色线条的资源。

忽略这里的资源,我们真正要做的只是将一些 ItemsControl 组合起来并将其绑定到我们的 ViewModel。

游戏下方,我们放置了一些命令和信息。

<!-- A padded grid is used to layout commands 
     and info at the bottom of the screen. -->
<apexControls:PaddedGrid 
 Grid.Row="1" Padding="4" 
 Columns="Auto,*,Auto,Auto,Auto,*,Auto">
<Button 
 Style="{StaticResource CasinoButtonStyle}"
 Grid.Column="0" Width="120" Content="Deal New Game"
 Command="{Binding DealNewGameCommand}" />
<TextBlock Grid.Column="2" 
   Style="{StaticResource CasinoTextStyle}" 
   VerticalAlignment="Center">
<Run Text="Score:" />
<Run Text="{Binding Score}" />
</TextBlock>
<TextBlock Grid.Column="3" 
  Style="{StaticResource CasinoTextStyle}" 
  VerticalAlignment="Center">
<Run Text="Moves:" />
<Run Text="{Binding Moves}" />
</TextBlock>
<TextBlock Grid.Column="4" 
  Style="{StaticResource CasinoTextStyle}" 
  VerticalAlignment="Center">
<Run Text="Time:" />
<Run Text="{Binding ElapsedTime, 
            Converter={StaticResource 
            TimeSpanToShortStringConverter}}" />
</TextBlock>
<Button 
 Style="{StaticResource CasinoButtonStyle}"
 Grid.Column="6" Width="120" Content="Go to Casino"
 Command="{Binding GoToCasinoCommand}" />
</apexControls:PaddedGrid>

我们使用带填充的网格来美观地布局(wpfpaddedgrid.aspx)。这很简单——我们有一个开始新游戏按钮,一个去赌场按钮,分数,时间,和步数。同样,资源稍后描述。

当游戏获胜时,我们只需叠加下面的 XAML——这就是为什么我们有 IsGameWon 属性。

<!-- The win overlay. --> 
<apexControls:ApexGrid 
 Rows="*,Auto,Auto,Auto,*" Grid.RowSpan="2" Background="#00FFFFFF"
 Visibility="{Binding IsGameWon, 
              Converter={StaticResource 
              BooleanToVisibilityConverter}}">


<TextBlock 
   Grid.Row="1" FontSize="34" 
   FontWeight="SemiBold" Foreground="#99FFFFFF"
   HorizontalAlignment="Center" Text="You Win!" />
<TextBlock
   Grid.Row="2" FontSize="18" Foreground="#99FFFFFF"
   HorizontalAlignment="Center" TextWrapping="Wrap">
<Run Text="You scored" />
<Run Text="{Binding Score}" />
<Run Text="in" />
<Run Text="{Binding Moves}" />
<Run Text="moves!" />
</TextBlock>
<StackPanel 
   Grid.Row="3" Orientation="Horizontal" 
   HorizontalAlignment="Center">
<Button 
   Style="{StaticResource CasinoButtonStyle}" Width="120" 
   Margin="4" Content="Play Again" 
   Command="{Binding DealNewGameCommand}" />
<Button 
   Style="{StaticResource CasinoButtonStyle}" Width="120" 
   Margin="4" Content="Back to Casino" 
   Command="{Binding GoToCasinoCommand}"  />
</StackPanel>
</apexControls:ApexGrid>
</apexControls:ApexGrid>
</UserControl>

就是这样——这就是克朗代克的视图!细心的人可能已经注意到我们有一些代码隐藏函数,让我们把它们添加进去。

构造函数将连接拖放主机

/// <summary>
/// Initializes a new instance of the
/// <see cref="KlondikeSolitaireView"/> class.
/// </summary>
public KlondikeSolitaireView()
{
    InitializeComponent();

    //  Wire up the drag and drop host.
    dragAndDropHost.DragAndDropStart += 
             new DragAndDropDelegate(Instance_DragAndDropStart);
    dragAndDropHost.DragAndDropContinue += 
             new DragAndDropDelegate(Instance_DragAndDropContinue);
    dragAndDropHost.DragAndDropEnd += 
             new DragAndDropDelegate(Instance_DragAndDropEnd);
}

我不会现在就深入探讨拖放的细节,但以下是大致情况

  • DragAndDropStart:当任何 DragAndDrop.IsDraggable 设置为 trueUIElement 被拖动时调用。
  • DragAndDropContinue:当拖动的元素移到 DragAndDrop.IsDropTarget 设置为 true 的元素上方时调用。
  • DragAndDropEnd:当拖动的元素在放置目标上方释放时调用。

为什么要自己编写?嗯,计划是它也将在 Silverlight 中工作——但这又是另一个话题了。

当拖放开始时,我们检查卡片是否正常,然后将它和它下面的卡片放入不可见的拖动堆栈中——这将是我们绘制的拖动装饰器。

void Instance_DragAndDropStart(object sender, DragAndDropEventArgs args)
{
    //  The data should be a playing card.
    PlayingCard card = args.DragData as PlayingCard;
    if (card == null || card.IsPlayable == false)
    {
        args.Allow = false;
        return;
    }
    args.Allow = true;

    //  If the card is draggable, we're going to want to drag the whole
    //  stack.
    IList<playingcard> cards = ViewModel.GetCardCollection(card);
    draggingCards = new List<playingcard>();
    int start = cards.IndexOf(card);
    for (int i = start; i < cards.Count; i++)
        draggingCards.Add(cards[i]);

    //  Clear the drag stack.
    dragStack.ItemsSource = draggingCards;
    dragStack.UpdateLayout();
    args.DragAdorner = new Apex.Adorners.VisualAdorner(dragStack);

    //  Hide each dragging card.
    ItemsControl sourceStack = args.DragSource as ItemsControl;
    foreach (var dragCard in draggingCards)
        ((ObservableCollection<playingcard>)
          sourceStack.ItemsSource).Remove(dragCard);
}

为什么使用自定义装饰器?Silverlight 是简短的答案。当我们拖动一张卡片时,如果它可以拖动,我们就会拖动它和它下面的所有卡片。我们通过将它们放入拖动堆栈来隐藏它们,拖动堆栈就是由装饰器绘制的。

void Instance_DragAndDropContinue(object sender, DragAndDropEventArgs args)
{
    args.Allow = true;
}

在这种情况下,继续函数是微不足道的,我们总是会让操作继续;当我们到达 DragAndDropEnd 时,我们才会检查一切是否正常。

拖放结束将牌从临时拖动堆栈移回源堆栈,然后就让视图模型接管。

void Instance_DragAndDropEnd(object sender, DragAndDropEventArgs args)
{
    //  We've put cards temporarily in the drag stack, put them in the 
    //  source stack again.
    ItemsControl sourceStack = args.DragSource as ItemsControl;
    foreach (var dragCard in draggingCards)
        ((ObservableCollection<playingcard>)
        ((ItemsControl)args.DragSource).ItemsSource).Add(dragCard);

    //  If we have a drop target, move the card.
    if (args.DropTarget != null)
    {
        //  Move the card.
        ViewModel.MoveCard(
            (ObservableCollection<playingcard>)((ItemsControl)args.DragSource).ItemsSource,
            (ObservableCollection<playingcard>)((ItemsControl)args.DropTarget).ItemsSource,
            (PlayingCard)args.DragData, false);
    }
}

我们还有一个 ViewModel 依赖属性,我们将在构建 Casino 时看到它的重要性。

/// <summary>
/// The ViewModel dependency property.
/// </summary>
private static readonly DependencyProperty ViewModelProperty =
  DependencyProperty.Register("ViewModel", 
  typeof(KlondikeSolitaireViewModel), typeof(KlondikeSolitaireView),
  new PropertyMetadata(new KlondikeSolitaireViewModel()));

/// <summary>
/// Gets or sets the view model.
/// </summary>
/// <value>The view model.</value>
public KlondikeSolitaireViewModel ViewModel
{
    get { return (KlondikeSolitaireViewModel)GetValue(ViewModelProperty); }
    set { SetValue(ViewModelProperty, value); }
}

View 代码隐藏的最后一部分是拖动卡片的临时存储,我们让不在卡片上的右键点击调用 TryMoveAllCards... 函数,以及在牌堆上的左键点击(即只有当牌堆为空时才处理)调用 Turn Stock 命令(这样我们可以点击空的牌堆将废牌堆中的牌翻回来)。

/// <summary>
/// Handles the MouseRightButtonDown event of the dragAndDropHost control.
/// </summary>
/// <param name="sender">The source of the event.</param>
/// <param name="e">The <see
// cref="System.Windows.Input.MouseButtonEventArgs">
// instance containing the event data.</param>
private void dragAndDropHost_MouseRightButtonDown(object sender, 
             MouseButtonEventArgs e)
{
    ViewModel.TryMoveAllCardsToAppropriateFoundations();
}

/// <summary>
/// Temporary storage for cards being dragged.
/// </summary>
private List<playingcard> draggingCards;

/// <summary>
/// Handles the MouseLeftButtonUp event of the CardStackControl control.
/// </summary>
/// <param name="sender">The source of the event.</param>
/// <param name="e">The <see
/// cref="System.Windows.Input.MouseButtonEventArgs">
/// instance containing the event data.</param>
private void CardStackControl_MouseLeftButtonUp(object sender, 
             MouseButtonEventArgs e)
{
    ViewModel.TurnStockCommand.DoExecute(null);
}

视图已完成。

步骤 4:资源

在之前创建的 View 中,我们引用了一个资源字典,现在让我们添加那个字典。

在 SolitaireGames 中创建一个名为 Resources 的文件夹,添加一个名为 SolitiareGamesResourceDictionary.xaml 的资源字典。

<ResourceDictionary 
 xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
 xmlns:sys="clr-namespace:System;assembly=mscorlib"
 xmlns:apexMVVM="clr-namespace:Apex.MVVM;assembly=Apex"
 xmlns:apexCommands="clr-namespace:Apex.Commands;assembly=Apex"
 xmlns:apexDragAndDrop="clr-namespace:Apex.DragAndDrop;assembly=Apex"
 xmlns:local="clr-namespace:SolitaireGames"
 xmlns:apexConverters="clr-namespace:Apex.Converters;assembly=Apex"
 xmlns:solitaireGames="clr-namespace:SolitaireGames"
 xmlns:klondike="clr-namespace:SolitaireGames.KlondikeSolitaire"
 xmlns:spider="clr-namespace:SolitaireGames.SpiderSolitaire">

<!-- Converters.-->
<apexConverters:BooleanToVisibilityConverter 
        x:Key="BooleanToVisibilityConverter" />
<solitaireGames:TimeSpanToShortStringConverter 
        x:Key="TimeSpanToShortStringConverter" />

首先是通常的命名空间声明。然后是几个转换器。Apex.Converters.BooleanToVisibilityConverter 就像标准的那样——只不过你可以将 Invert 作为其参数传递,它会反转结果。

在克朗代克视图中,我们显示一个时间跨度,但我们希望它以特定的格式显示,为此,我们有一个转换器。它是一个如此简单的类,我将直接在下面放置它(它被称为 TimeSpanToShortStringConverter)。

using System;
using System.Windows.Data;
namespace SolitaireGames
{
    /// <summary>
    /// A converter that turns a time span into a small string, only 
    /// suitable for up to 24 hours.
    /// </summary>
    class TimeSpanToShortStringConverter : IValueConverter
    {
        /// <summary>
        /// Converts a value.
        /// </summary>
        /// <param name="value">The value produced by the binding source.</param>
        /// <param name="targetType">The type of the binding target property.</param>
        /// <param name="parameter">The converter parameter to use.</param>
        /// <param name="culture">The culture to use in the converter.</param>
        /// <returns>
        /// A converted value. If the method returns null,
        /// the valid null value is used.
        /// </returns>
        public object Convert(object value, Type targetType, 
               object parameter, System.Globalization.CultureInfo culture)
        {
            TimeSpan timeSpan = (TimeSpan)value;
            if(timeSpan.Hours > 0)
                return string.Format("{0:D2}:{1:D2}:{2:D2}",
                        timeSpan.Hours,
                        timeSpan.Minutes,
                        timeSpan.Seconds);
            else
                return string.Format("{0:D2}:{1:D2}",
                        timeSpan.Minutes,
                        timeSpan.Seconds);
        }

        /// <summary>
        /// Converts a value.
        /// </summary>
        /// <param name="value">The value that
        /// is produced by the binding target.</param>
        /// <param name="targetType">The type to convert to.</param>
        /// <param name="parameter">The converter parameter to use.</param>
        /// <param name="culture">The culture to use in the converter.</param>
        /// <returns>
        /// A converted value. If the method returns null, the valid null value is used.
        /// </returns>
        public object ConvertBack(object value, Type targetType, 
               object parameter, System.Globalization.CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }
}

我们有的下一个转换器是扑克牌到画刷的转换器,它将接受一个 PlayingCard 对象并返回合适的画刷。如果您查看示例代码,您会看到 Resources 包含一个名为 Decks 的文件夹,其中包含四个牌组文件夹:ClassicHeartsLarge PrintSeasons。每个文件夹都包含一张图片,其中每种牌型都有一个名称(例如,S2.png 是黑桃 2,HA 是红心 A),以及一张 Back.png 作为牌的背面图片。

这些资源是从 Windows 7 纸牌游戏中提取并使用 Photoshop 进行清理、裁剪等操作的(600 多个文件,花了一段时间,感谢 PhotoShop 批量处理)。PlayingCardToBrushConverter 将允许我们将 PlayingCard 转换为适合它的画刷——画刷存储在字典中,因此我们只在需要时创建它们。额外的牌组功能是后来添加的,所以有点笨拙,但它有效!

同样,它是一个简单的转换器,所以我不会深入细节。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows.Data;
using System.Globalization;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace SolitaireGames
{
    /// <summary>
    /// Converter to get the brush for a playing card.
    /// </summary>
    public class PlayingCardToBrushConverter : IMultiValueConverter
    {
        /// <summary>
        /// Sets the deck folder.
        /// </summary>
        /// <param name="folderName">Name of the folder.</param>
        public static void SetDeckFolder(string folderName)
        {
            //  Clear the dictionary so we recreate card brushes.
            brushes.Clear();

            //  Set the deck folder.
            deckFolder = folderName;
        }

        /// <summary>
        /// The default deck folder.
        /// </summary>
        static string deckFolder = "Classic";

        /// <summary>
        /// A dictionary of brushes for card types.
        /// </summary>
        static Dictionary<string,> brushes = new Dictionary<string,>();

        /// <summary>
        /// Converts source values to a value for the binding target.
        /// The data binding engine calls this method when it propagates
        /// the values from source bindings to the binding target.
        /// </summary>
        /// <param name="values">The array of values that
        /// the source bindings in the <see cref="T:System.Windows.Data.MultiBinding">
        /// produces. The value <see cref="F:System.Windows.DependencyProperty.UnsetValue">
        /// indicates that the source binding has
        /// no value to provide for conversion.</param>
        /// <param name="targetType">The type of the binding target property.</param>
        /// <param name="parameter">The converter parameter to use.</param>
        /// <param name="culture">The culture to use in the converter.</param>
        /// <returns>
        /// A converted value.If the method returns null, the valid null value is used.
        /// A return value of <see cref="T:System.Windows.DependencyProperty">.
        /// <see cref="F:System.Windows.DependencyProperty.UnsetValue"> indicates
        /// that the converter did not produce a value, and that the binding will use the
        /// <see cref="P:System.Windows.Data.BindingBase.FallbackValue">
        /// if it is available, or else will use the default value.A return value of
        /// <see cref="T:System.Windows.Data.Binding">.<see
        /// cref="F:System.Windows.Data.Binding.DoNothing"> indicates that
        /// the binding does not transfer the value or use the
        /// <see cref="P:System.Windows.Data.BindingBase.FallbackValue">
        /// or the default value.
        /// </see></see></see></see></see></see></returns>
        public object Convert(object[] values, Type targetType, 
               object parameter, CultureInfo culture)
        {
            //  Cast the data.
            if (values == null || values.Count() != 2)
                return null;

            //  Cast the values.
            CardType cardType = (CardType)values[0];
            bool faceDown = (bool)values[1];

            //  We're going to create an image source.
            string imageSource = string.Empty;

            //  If the card is face down, we're using the 'Rear' image.
            //  Otherwise it's just the enum value (e.g. C3, SA).
            if (faceDown)
                imageSource = "Back";
            else
                imageSource = cardType.ToString();

            //  Turn this string into a proper path.
            imageSource = 
              "pack://application:,,,/SolitaireGames;component/Resources/Decks/" + 
              deckFolder + "/" + imageSource + ".png";

            //  Do we need to add this brush to the static dictionary?
            if (brushes.ContainsKey(imageSource) == false)
                brushes.Add(imageSource, new ImageBrush(
                new BitmapImage(new Uri(imageSource))));

            //  Return the brush.
            return brushes[imageSource];
        }

        /// <summary>
        /// Converts a binding target value to the source binding values.
        /// </summary>
        /// <param name="value">The value that the binding target produces.</param>
        /// <param name="targetTypes">The array of types to convert to.
        /// The array length indicates the number and types of values
        /// that are suggested for the method to return.</param>
        /// <param name="parameter">The converter parameter to use.</param>
        /// <param name="culture">The culture to use in the converter.</param>
        /// <returns>
        /// An array of values that have been converted from
        /// the target value back to the source values.
        /// </returns>
        public object[] ConvertBack(object value, Type[] targetTypes, 
               object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }
}

本质上,我们以字符串形式写入枚举值,并从一些硬编码值构建路径。

回到字典 XAML,最重要的数据模板紧随其后

<!-- The playing card data template. -->
<DataTemplate DataType="{x:Type solitaireGames:PlayingCard}">
<Border
 Width="140" Height="190" Cursor="Hand"
 BorderThickness="1" CornerRadius="6"
 apexDragAndDrop:DragAndDrop.IsDraggable="True"
 apexCommands:ExtendedCommands.RightClickCommand="{Binding RelativeSource={RelativeSource 
 FindAncestor, AncestorType={x:Type UserControl}}, Path=ViewModel.RightClickCardCommand}"
 apexCommands:ExtendedCommands.RightClickCommandParameter="{Binding }"
>
<apexCommands:EventBindings.EventBindings>
    <apexCommands:EventBindingCollection>
        <apexCommands:EventBinding 
            EventName="MouseLeftButtonUp"
            Command="{Binding RelativeSource={RelativeSource FindAncestor, 
              AncestorType={x:Type UserControl}}, 
              Path=ViewModel.LeftClickCardCommand}" 
            CommandParameter="{Binding}" />
    </apexCommands:EventBindingCollection>
</apexCommands:EventBindings.EventBindings>
<Border.Background>
    <MultiBinding Converter="{StaticResource PlayingCardToBrushConverter}">
        <Binding Path="CardType" />
        <Binding Path="IsFaceDown" />
    </MultiBinding>
</Border.Background>

本质上,扑克牌被绘制为带有我们刚刚看到的转换器提供的画刷的边框。DragAndDrop.IsDraggable 在这里又露出了它丑陋的脑袋,确保了卡牌可以被拖动。RightClickCommand 假定在视觉树的某个位置,我们有一个派生自 CardGameViewModel 的 ViewModel。接下来是左键单击的事件绑定。

事件绑定和鼠标抬起

为什么不直接使用 apexCommands:ExtendedCommands.LeftClickCommand,而非更冗长的 Apex 事件绑定呢?嗯,在这种情况下,我们希望只有在鼠标释放时才注册卡片的左键单击,否则它会与拖放中使用的事件发生冲突。EventBinding 很有趣,它会将任何事件路由到命令,处理数据上下文等。

最后,对于这张牌,我们有一个补丁——正面图像太白了,我们需要一个边框,但背面图像很好——所以我们只在正面朝上时绘制边框。

<Border.Style>
<Style TargetType="Border">
<Style.Triggers>
<DataTrigger Binding="{Binding IsFaceDown}" Value="True">
<Setter Property="BorderBrush" Value="#00ffffff" />
</DataTrigger>
<DataTrigger Binding="{Binding IsFaceDown}" Value="False">
<Setter Property="BorderBrush" Value="#ff666666" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
</Border>
</DataTemplate>

资源字典的下一部分定义了 CardStackControl 的样式。这有点奇怪,因为它本质上是将自身的属性传递给其布局面板,即 CardStackPanelCardStack 有点特殊,由于它们对于理解应用程序的工作方式并不关键,因此在 附录 1 中详细说明。然而,这是 XAML。

<!-- The style for the card stack control. -->
<Style TargetType="{x:Type solitaireGames:CardStackControl}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type solitaireGames:CardStackControl}">
<Border Background="{TemplateBinding Background}"
 BorderBrush="{TemplateBinding BorderBrush}"
 BorderThickness="{TemplateBinding BorderThickness}">
<ItemsControl ItemsSource="{TemplateBinding ItemsSource}"
 apexDragAndDrop:DragAndDrop.IsDragSource="True"
 apexDragAndDrop:DragAndDrop.IsDropTarget="True"
 Background="Transparent">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<solitaireGames:CardStackPanel 
 FaceDownOffset="{Binding FaceDownOffset, RelativeSource=
   {RelativeSource AncestorType={x:Type solitaireGames:CardStackControl}}}"
 FaceUpOffset="{Binding FaceUpOffset, RelativeSource=
   {RelativeSource AncestorType={x:Type solitaireGames:CardStackControl}}}"
 OffsetMode="{Binding OffsetMode, RelativeSource=
   {RelativeSource AncestorType={x:Type solitaireGames:CardStackControl}}}"
 NValue="{Binding NValue, RelativeSource=
   {RelativeSource AncestorType={x:Type solitaireGames:CardStackControl}}}"
 Orientation="{Binding Orientation, RelativeSource=
   {RelativeSource AncestorType={x:Type solitaireGames:CardStackControl}}}" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>

接下来的四种样式用于保持文本外观一致,堆栈和运行的弯曲边框,以及在绿色毛毡背景上看起来不错的圆形按钮。

<!--The style for text etc in a game. -->
<Style x:Key="CasinoTextStyle" TargetType="TextBlock">
<Setter Property="Foreground" Value="#99FFFFFF" />
<Setter Property="FontSize" Value="16" />
</Style>

<!-- The style for a stack marker. -->
<Style x:Key="StackMarker" TargetType="Border">
<Setter Property="Padding" Value="10" />
<Setter Property="BorderThickness" Value="6" />
<Setter Property="CornerRadius" Value="15" />
<Setter Property="BorderBrush" Value="#33FFFFFF" />
<Setter Property="Margin" Value="8,10,40,60" />
</Style>
<!-- Style for a run marker. -->
<Style x:Key="RunMarker" TargetType="Border">
<Setter Property="Padding" Value="10" />
<Setter Property="BorderThickness" Value="6" />
<Setter Property="CornerRadius" Value="15" />
<Setter Property="BorderBrush">
<Setter.Value>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#33FFFFFF" Offset="0" />
<GradientStop Color="#00FFFFFF" Offset="0.8" />
</LinearGradientBrush>
</Setter.Value>
</Setter>
<Setter Property="Margin" Value="8,10,40,40" />
</Style>
<!-- A nice clean style for a button. -->
<Style x:Key="CasinoButtonStyle" TargetType="Button">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border 
   Padding="4" BorderThickness="2" 
   CornerRadius="15" BorderBrush="#66FFFFFF"
   Background="#11FFFFFF"
   Cursor="Hand">
<ContentPresenter 
   TextBlock.Foreground="#99FFFFFF"
   TextBlock.FontWeight="SemiBold"
   HorizontalAlignment="Center"
   Content="{TemplateBinding Content}"
   />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>

整个应用程序中我们只数据绑定了两个枚举——克朗代克的抽牌模式和蜘蛛纸牌的难度,所以我们用它们的数据提供程序来完成资源字典。

<!-- Data provider for DrawMode. -->
<ObjectDataProvider 
 MethodName="GetValues" 
 ObjectType="{x:Type sys:Enum}" x:Key="DrawModeValues">
<ObjectDataProvider.MethodParameters>
<x:Type TypeName="klondike:DrawMode" />
</ObjectDataProvider.MethodParameters>
</ObjectDataProvider>

<!-- Data provider for Difficulty. -->
<ObjectDataProvider 
   MethodName="GetValues" 
   ObjectType="{x:Type sys:Enum}" 
   x:Key="DifficultyValues">
<ObjectDataProvider.MethodParameters>
<x:Type TypeName="spider:Difficulty" />
</ObjectDataProvider.MethodParameters>
</ObjectDataProvider>
</ResourceDictionary>

步骤 5:蜘蛛纸牌

如果我在这里详细介绍蜘蛛纸牌,文章会太长。代码在示例中,与克朗代克代码非常相似——我们有一个视图模型和视图,一组 Tableau 等。如果您能理解克朗代克代码,那么蜘蛛纸牌代码也没问题。如果您正在按照文章一步一步地构建项目,您现在可以添加 SpiderSolitiare 文件夹,并从页面顶部的下载中拖入文件。

步骤 6:赌场

赌场将是我们的主页或所有纸牌游戏的中心。它将包含两个游戏的视图模型以及一些统计数据。事实上,统计数据是我们将要处理的下一件事。让我们为一些统计数据创建一个视图模型。向 SolitaireGames 添加一个名为 GameStatistics.cs 的文件。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Apex.MVVM;

namespace SolitaireGames
{
    /// <summary>
    /// A set of general statistics for a game.
    /// </summary>
    public class GameStatistics : ViewModel
    {
        /// <summary>
        /// The game name property.
        /// </summary>
        private NotifyingProperty GameNameProperty =
          new NotifyingProperty("GameName", 
          typeof(string), default(string));

        /// <summary>
        /// Gets or sets the name of the game.
        /// </summary>
        /// <value>The name of the game.</value>
        public string GameName
        {
            get { return (string)GetValue(GameNameProperty); }
            set { SetValue(GameNameProperty, value); }
        }
        
        /// <summary>
        /// The games played property.
        /// </summary>
        private NotifyingProperty GamesPlayedProperty =
          new NotifyingProperty("GamesPlayed", 
          typeof(int), default(int));

        /// <summary>
        /// Gets or sets the games played.
        /// </summary>
        /// <value>The games played.</value>
        public int GamesPlayed
        {
            get { return (int)GetValue(GamesPlayedProperty); }
            set { SetValue(GamesPlayedProperty, value); }
        }

        /// <summary>
        /// The games won property.
        /// </summary>
        private NotifyingProperty GamesWonProperty =
          new NotifyingProperty("GamesWon", 
          typeof(int), default(int));

        /// <summary>
        /// Gets or sets the games won.
        /// </summary>
        /// <value>The games won.</value>
        public int GamesWon
        {
            get { return (int)GetValue(GamesWonProperty); }
            set { SetValue(GamesWonProperty, value); }
        }

        /// <summary>
        /// The games lost property.
        /// </summary>
        private NotifyingProperty GamesLostProperty =
          new NotifyingProperty("GamesLost", 
          typeof(int), default(int));

        /// <summary>
        /// Gets or sets the games lost.
        /// </summary>
        /// <value>The games lost.</value>
        public int GamesLost
        {
            get { return (int)GetValue(GamesLostProperty); }
            set { SetValue(GamesLostProperty, value); }
        }
        
        /// <summary>
        /// The highest winning streak property.
        /// </summary>
        private NotifyingProperty HighestWinningStreakProperty =
          new NotifyingProperty("HighestWinningStreak", 
          typeof(int), default(int));

        /// <summary>
        /// Gets or sets the highest winning streak.
        /// </summary>
        /// <value>The highest winning streak.</value>
        public int HighestWinningStreak
        {
            get { return (int)GetValue(HighestWinningStreakProperty); }
            set { SetValue(HighestWinningStreakProperty, value); }
        }

        /// <summary>
        /// The highest losing streak.
        /// </summary>
        private NotifyingProperty HighestLosingStreakProperty =
          new NotifyingProperty("HighestLosingStreak", 
          typeof(int), default(int));

        /// <summary>
        /// Gets or sets the highest losing streak.
        /// </summary>
        /// <value>The highest losing streak.</value>
        public int HighestLosingStreak
        {
            get { return (int)GetValue(HighestLosingStreakProperty); }
            set { SetValue(HighestLosingStreakProperty, value); }
        }
        
        /// <summary>
        /// The current streak.
        /// </summary>
        private NotifyingProperty CurrentStreakProperty =
          new NotifyingProperty("CurrentStreak", 
          typeof(int), default(int));

        /// <summary>
        /// Gets or sets the current streak.
        /// </summary>
        /// <value>The current streak.</value>
        public int CurrentStreak
        {
            get { return (int)GetValue(CurrentStreakProperty); }
            set { SetValue(CurrentStreakProperty, value); }
        }

        /// <summary>
        /// The cumulative score.
        /// </summary>
        private NotifyingProperty CumulativeScoreProperty =
          new NotifyingProperty("CumulativeScore", 
          typeof(int), default(int));

        /// <summary>
        /// Gets or sets the cumulative score.
        /// </summary>
        /// <value>The cumulative score.</value>
        public int CumulativeScore
        {
            get { return (int)GetValue(CumulativeScoreProperty); }
            set { SetValue(CumulativeScoreProperty, value); }
        }
        
        /// <summary>
        /// The highest score.
        /// </summary>
        private NotifyingProperty HighestScoreProperty =
          new NotifyingProperty("HighestScore", 
          typeof(int), default(int));

        /// <summary>
        /// Gets or sets the highest score.
        /// </summary>
        /// <value>The highest score.</value>
        public int HighestScore
        {
            get { return (int)GetValue(HighestScoreProperty); }
            set { SetValue(HighestScoreProperty, value); }
        }

        /// <summary>
        /// The average score.
        /// </summary>
        private NotifyingProperty AverageScoreProperty =
          new NotifyingProperty("AverageScore", 
          typeof(double), default(double));

        /// <summary>
        /// Gets or sets the average score.
        /// </summary>
        /// <value>The average score.</value>
        public double AverageScore
        {
            get { return (double)GetValue(AverageScoreProperty); }
            set { SetValue(AverageScoreProperty, value); }
        }

        /// <summary>
        /// The cumulative game time.
        /// </summary>
        private NotifyingProperty CumulativeGameTimeProperty =
          new NotifyingProperty("CumulativeGameTime", 
          typeof(double), default(double));

        /// <summary>
        /// Gets or sets the cumulative game time.
        /// </summary>
        /// <value>The cumulative game time.</value>
        public TimeSpan CumulativeGameTime
        {
            get { return TimeSpan.FromSeconds(
                   (double)GetValue(CumulativeGameTimeProperty)); }
            set { SetValue(CumulativeGameTimeProperty, value.TotalSeconds); }
        }
        
        /// <summary>
        /// The average game time.
        /// </summary>
        private NotifyingProperty AverageGameTimeProperty =
          new NotifyingProperty("AverageGameTime", 
          typeof(double), default(double));

        /// <summary>
        /// Gets or sets the average game time.
        /// </summary>
        /// <value>The average game time.</value>
        public TimeSpan AverageGameTime
        {
            get { return TimeSpan.FromSeconds(
                    (double)GetValue(AverageGameTimeProperty)); }
            set { SetValue(AverageGameTimeProperty, value.TotalSeconds); }
        }

        /// <summary>
        /// The reset command.
        /// </summary>
        private ViewModelCommand resetCommand;

        /// <summary>
        /// Gets the reset command.
        /// </summary>
        /// <value>The reset command.</value>
        public ViewModelCommand ResetCommand
        {
            get { return resetCommand; }
        }

统计数据 ViewModel 主要只是一组属性——但我们还需要一个构造函数来连接 ViewModel 命令和重置命令。

序列化 TimeSpan

您可能已经注意到,尽管通知属性 AverageGameTimeCumulativeGameTime 作为 TimeSpan 暴露,但它们被存储并定义为 double。这是因为我们将要序列化整个对象,而 TimeSpan 对象不能使用 XmlSerializer 进行序列化!

下面是构造函数和重置命令。

/// <summary>
/// Initializes a new instance of the <see cref="GameStatistics"> class.
/// </summary>
public GameStatistics()
{
    //  Create the reset command.
    resetCommand = new ViewModelCommand(DoReset, true);
}

/// <summary>
/// Resets the statistics.
/// </summary>
private void DoReset()
{
    GamesPlayed = 0;
    GamesWon = 0; 
    GamesLost = 0; 
    HighestWinningStreak = 0; 
    HighestLosingStreak = 0;
    CurrentStreak = 0; 
    CumulativeScore = 0; 
    HighestScore = 0; 
    AverageScore = 0; 
    CumulativeGameTime = TimeSpan.FromSeconds(0); 
    AverageGameTime = TimeSpan.FromSeconds(0);
}

最终也是最重要的函数是 UpdateStatistics 函数——它将从 CardGameViewModel 对象更新统计数据。

/// <summary>
/// Updates the statistics based on a won game.
/// </summary>
/// <param name="cardGame">The card game.</param>
public void UpdateStatistics(CardGameViewModel cardGame)
{
    //  Update the games won or lost.
    GamesPlayed++;
    if (cardGame.IsGameWon)
        GamesWon++;
    else
        GamesLost++;

    //  Update the current streak.
    if (cardGame.IsGameWon)
        CurrentStreak = CurrentStreak < 0 ? 1 : CurrentStreak + 1;
    else
        CurrentStreak = CurrentStreak > 0 ? -1 : CurrentStreak - 1;

    //  Update the highest streaks.
    if (CurrentStreak > HighestWinningStreak)
        HighestWinningStreak = CurrentStreak;
    else if (Math.Abs(CurrentStreak) > HighestLosingStreak)
        HighestLosingStreak = Math.Abs(CurrentStreak);

    //  Update the highest score.
    if (cardGame.Score > HighestScore)
        HighestScore = cardGame.Score;

    //  Update the average score. Only won games
    //  contribute to the running average.
    if (cardGame.IsGameWon)
    {
        CumulativeScore += cardGame.Score;
        AverageScore = CumulativeScore / GamesWon;
    }

    //  Update the average game time.
    CumulativeGameTime += cardGame.ElapsedTime;
    AverageGameTime = 
      TimeSpan.FromTicks(CumulativeGameTime.Ticks / (GamesWon + GamesLost));
}

GameStatistics 完成了。现在让我们构建 Casino View Model——这将是一个包含所有其他内容的大型模型。在 SolitaireGames 中添加一个名为 Casino 的文件夹。现在添加一个名为 CasinoViewModel 的类。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Apex.MVVM;
using SolitaireGames.KlondikeSolitaire;
using System.IO.IsolatedStorage;
using System.IO;
using System.Xml.Serialization;
using SolitaireGames.SpiderSolitaire;

namespace SolitaireGames.Casino
{
    /// <summary>
    /// The casino view model.
    /// </summary>
    public class CasinoViewModel : ViewModel
    {
        /// <summary>
        /// Initializes a new instance of the
        /// <see cref="CasinoViewModel"> class.
        /// </summary>
        public CasinoViewModel()
        {
            //  Create the commands.
            goToCasinoCommand = new ViewModelCommand(DoGoToCasino, true);
            goToKlondikeSolitaireCommand = 
              new ViewModelCommand(DoGoToKlondikeSolitaire, true);
            goToSpiderSolitaireCommand =
              new ViewModelCommand(DoGoToSpiderSolitaire, true);
            settingsCommand = new ViewModelCommand(DoSettingsCommand, true);
        }

        /// <summary>
        /// The go to casino command.
        /// </summary>
        private ViewModelCommand goToCasinoCommand;

        /// <summary>
        /// The go to klondike command.
        /// </summary>
        private ViewModelCommand goToKlondikeSolitaireCommand;

        /// <summary>
        /// The spider command.
        /// </summary>
        private ViewModelCommand goToSpiderSolitaireCommand;

        /// <summary>
        /// The settings command.
        /// </summary>
        private ViewModelCommand settingsCommand;

        /// <summary>
        /// Gets the go to casino command.
        /// </summary>
        /// <value>The go to casino command.</value>
        public ViewModelCommand GoToCasinoCommand
        {
            get { return goToCasinoCommand; }
        }

        /// <summary>
        /// Gets the go to klondike solitaire command.
        /// </summary>
        /// <value>The go to klondike solitaire command.</value>
        public ViewModelCommand GoToKlondikeSolitaireCommand
        {
            get { return goToKlondikeSolitaireCommand; }
        }

        /// <summary>
        /// Gets the go to spider solitaire command.
        /// </summary>
        /// <value>The go to spider solitaire command.</value>
        public ViewModelCommand GoToSpiderSolitaireCommand
        {
            get { return goToSpiderSolitaireCommand; }
        }

        /// <summary>
        /// Gets the settings command.
        /// </summary>
        /// <value>The settings command.</value>
        public ViewModelCommand SettingsCommand
        {
            get { return settingsCommand; }
        }
        
        /// <summary>
        /// Goes to the casino.
        /// </summary>
        private void DoGoToCasino()
        {
            KlondikeSolitaireViewModel.StopTimer();
            SpiderSolitaireViewModel.StopTimer();
        }

        /// <summary>
        /// Goes to spider.
        /// </summary>
        private void DoGoToSpiderSolitaire()
        {
            if(SpiderSolitaireViewModel.Moves > 0)
                SpiderSolitaireViewModel.StartTimer();
        }

        /// <summary>
        /// Goes to Klondike.
        /// </summary>
        private void DoGoToKlondikeSolitaire()
        {
            if(KlondikeSolitaireViewModel.Moves > 0)
                KlondikeSolitaireViewModel.StartTimer();
        }

        /// <summary>
        /// The settings command.
        /// </summary>
        private void DoSettingsCommand()
        {
        }

(再次,我以一种更容易分解的顺序来描述这个类,而不是它在实际文件中定义的顺序)。我们首先有四个命令——每个命令对应一个我们可以导航到的地方。

当我们从一个地方移动到另一个地方时,我们启动或停止相应的游戏计时器,但我们实际上如何移动呢?嗯,由于当前屏幕是视图关注的问题,我们让视图监听命令并根据其认为合适的方式处理它们——ViewModel 只处理数据和逻辑方面的事情。例如,DoSettingsCommand 实际上什么都不做——但是,稍后,我们将有一个视图监听此命令并在它触发时更改当前屏幕。

接下来我们有克朗代克和蜘蛛视图模型以及统计数据。

/// <summary>
/// The Klondike stats.
/// </summary>
private NotifyingProperty KlondikeSolitaireStatisticsProperty =
  new NotifyingProperty("KlondikeSolitaireStatistics", typeof(GameStatistics),
      new GameStatistics() { GameName = "Klondike Solitaire" });

/// <summary>
/// Gets or sets the klondike solitaire statistics.
/// </summary>
/// <value>The klondike solitaire statistics.</value>
public GameStatistics KlondikeSolitaireStatistics
{
    get { return (GameStatistics)GetValue(KlondikeSolitaireStatisticsProperty); }
    set { SetValue(KlondikeSolitaireStatisticsProperty, value); }
}

/// <summary>
/// The spider stats.
/// </summary>
private NotifyingProperty SpiderSolitaireStatisticsProperty =
  new NotifyingProperty("SpiderSolitaireStatistics", typeof(GameStatistics),
      new GameStatistics() { GameName = "Spider Solitaire" });

/// <summary>
/// Gets or sets the spider solitaire statistics.
/// </summary>
/// <value>The spider solitaire statistics.</value>
public GameStatistics SpiderSolitaireStatistics
{
    get { return (GameStatistics)GetValue(SpiderSolitaireStatisticsProperty); }
    set { SetValue(SpiderSolitaireStatisticsProperty, value); }
}

/// <summary>
/// The Klondike view model.
/// </summary>
private NotifyingProperty KlondikeSolitaireViewModelProperty =
  new NotifyingProperty("KlondikeSolitaireViewModel", 
      typeof(KlondikeSolitaireViewModel), 
      new KlondikeSolitaireViewModel());

/// <summary>
/// Gets or sets the klondike solitaire view model.
/// </summary>
/// <value>The klondike solitaire view model.</value>
public KlondikeSolitaireViewModel KlondikeSolitaireViewModel
{
    get { return (KlondikeSolitaireViewModel)GetValue(
                  KlondikeSolitaireViewModelProperty); }
    set { SetValue(KlondikeSolitaireViewModelProperty, value); }
}

/// <summary>
/// The spider solitaire view model.
/// </summary>
private NotifyingProperty SpiderSolitaireViewModelProperty =
  new NotifyingProperty("SpiderSolitaireViewModel", 
  typeof(SpiderSolitaireViewModel), 
  new SpiderSolitaireViewModel());

/// <summary>
/// Gets or sets the spider solitaire view model.
/// </summary>
/// <value>The spider solitaire view model.</value>
public SpiderSolitaireViewModel SpiderSolitaireViewModel
{
    get { return (SpiderSolitaireViewModel)GetValue(
                  SpiderSolitaireViewModelProperty); }
    set { SetValue(SpiderSolitaireViewModelProperty, value); }
}

正如我们所看到的,CasinoViewModel 包含了游戏的视图模型。

嵌套的 ViewModel

在设计模式方面,对于嵌套的 ViewModel 等如何工作有各种不同的看法。在一个可扩展的设计中,我们可能会在赌场中有一个游戏列表,并使用 Managed Extensible Framework 允许我们随意添加游戏。然而,在这种情况下,我们只是将子 ViewModel 作为属性——在这种情况下它能完成工作,但在其他项目中,这可能是一种需要仔细考虑的做法。

我们将允许通过组合框选择牌组。您可能还记得,牌组是在最后一刻添加的,所以这不是很干净,但再次强调,它有效。我本来可以整理一下,但我最终想发布它。

/// <summary>
/// The selected deck folder.
/// </summary>
private NotifyingProperty DeckFolderProperty =
  new NotifyingProperty("DeckFolder", typeof(string), "Classic");

/// <summary>
/// Gets or sets the deck folder.
/// </summary>
/// <value>The deck folder.</value>
public string DeckFolder
{
    get { return (string)GetValue(DeckFolderProperty); }
    set 
    { 
        SetValue(DeckFolderProperty, value);
        PlayingCardToBrushConverter.SetDeckFolder(value);
    }
}

/// <summary>
/// The set of available deck folders.
/// </summary>
private List<string> deckFolders = 
  new List<string>() { "Classic", "Hearts", "Seasons", "Large Print" };

/// <summary>
/// Gets the deck folders.
/// </summary>
/// <value>The deck folders.</value>
[XmlIgnore]
public List<string> DeckFolders
{
    get { return deckFolders; }
}

现在对于 MVVM 专家来说,您可能对嵌套的 ViewModel 不是很满意——下一部分甚至更加大胆。

/// <summary>
/// Saves this instance.
/// </summary>
public void Save()
{
    // Get a new isolated store for this user, domain, and assembly.
    IsolatedStorageFile isoStore = 
        IsolatedStorageFile.GetStore(IsolatedStorageScope.User |
        IsolatedStorageScope.Domain | IsolatedStorageScope.Assembly, 
        null, null);

    //  Create data stream.
    using (IsolatedStorageFileStream isoStream =
        new IsolatedStorageFileStream("Casino.xml", 
        FileMode.Create, isoStore))
    {
        XmlSerializer casinoSerializer = 
          new XmlSerializer(typeof(CasinoViewModel));
        casinoSerializer.Serialize(isoStream, this);
    }
}

/// <summary>
/// Loads this instance.
/// </summary>
/// <returns>
public static CasinoViewModel Load()
{
    // Get a new isolated store for this user, domain, and assembly.
    IsolatedStorageFile isoStore = 
        IsolatedStorageFile.GetStore(IsolatedStorageScope.User |
        IsolatedStorageScope.Domain | IsolatedStorageScope.Assembly, 
        null, null);

    //  Create data stream.
    try
    {
        //  Save the casino.
        using (IsolatedStorageFileStream isoStream =
            new IsolatedStorageFileStream("Casino.xml", 
            FileMode.Open, isoStore))
        {
            XmlSerializer casinoSerializer = 
              new XmlSerializer(typeof(CasinoViewModel));
            return (CasinoViewModel)casinoSerializer.Deserialize(isoStream);
        }
    }
    catch
    {
    }

    return new CasinoViewModel();
}

两个函数——SaveLoad。它们允许我们持久化整个赌场。

序列化 ViewModel

从 MVVM 的角度来看,这不应该这样做。ViewModel 是表示逻辑——它是 View 和 Model 之间的桥梁——**Model** 才应该用于持久化数据。然而,这完成了任务——它是一个小型应用程序,我们可以根据需要调整模式。

如果您打算像这样打破 MVVM 等模式的规则,那么理解这些规则就非常重要——您必须理解您正在构建的限制。在这种情况下,这个应用程序不会在多年内构建,客户更改请求的成本也很高,等等,所以我们可以按照对我们有效的方式使用该模式——它不需要在数据存储机制方面具有可扩展性。

如前所述,您应该非常仔细地考虑在“严肃”应用程序中这样做,因为如果 ViewModel 的结构发生变化,它将导致问题。

一旦赌场被加载或创建,我们需要连接一些事件等等,所以让我们给它一个 Initialise 函数来执行一次性初始化。

/// <summary>
/// Initialises this instance.
/// </summary>
public void Initialise()
{
    //  We're going to listen out for certain commands in the game
    //  so that we can keep track of scores etc.
    KlondikeSolitaireViewModel.DealNewGameCommand.Executed += 
      new CommandEventHandler(KlondikeDealNewGameCommand_Executed);
    KlondikeSolitaireViewModel.GameWon += 
      new Action(KlondikeSolitaireViewModel_GameWon);
    KlondikeSolitaireViewModel.GoToCasinoCommand.Executed += 
      new CommandEventHandler(GoToCasinoCommand_Executed);
    SpiderSolitaireViewModel.DealNewGameCommand.Executed += 
      new CommandEventHandler(SpiderDealNewGameCommand_Executed);
    SpiderSolitaireViewModel.GameWon += 
      new Action(SpiderSolitaireViewModel_GameWon);
    SpiderSolitaireViewModel.GoToCasinoCommand.Executed +=
      new CommandEventHandler(GoToCasinoCommand_Executed);

    //  Set the deck we're using for brushes.
    PlayingCardToBrushConverter.SetDeckFolder(DeckFolder);
}

这里发生了什么?嗯,我们正在监听子 ViewModel 触发的某些命令。

Apex 命令

一个 Apex 命令会触发两个事件——在命令执行前触发 Executing,允许命令被取消;在命令执行后触发 Executed。我们可以在视图或其他视图模型中使用这些事件来知道何时触发了 ViewModelCommand

我们为什么要监听这些命令呢?嗯,其实只是为了保存(以防游戏意外结束)和更新统计数据。

/// <summary>
/// Called when Klondike is won.
/// </summary>
void KlondikeSolitaireViewModel_GameWon()
{
    //  The game was won, update the stats.
    KlondikeSolitaireStatistics.UpdateStatistics(KlondikeSolitaireViewModel);
    Save();
}

/// <summary>
/// Called when Spider is won.
/// </summary>
void SpiderSolitaireViewModel_GameWon()
{
    //  The game was won, update the stats.
    SpiderSolitaireStatistics.UpdateStatistics(KlondikeSolitaireViewModel);
    Save();
}

/// <summary>
/// Handles the Executing event of the DealNewGameCommand control.
/// </summary>
/// <param name="sender">The source of the event.</param>
/// <param name="args">The <see
/// cref="Apex.MVVM.CommandEventArgs"> instance
/// containing the event data.</param>
void KlondikeDealNewGameCommand_Executed(object sender, CommandEventArgs args)
{
    //  If we've made any moves, update the stats.
    if (KlondikeSolitaireViewModel.Moves > 0)
        KlondikeSolitaireStatistics.UpdateStatistics(KlondikeSolitaireViewModel);
    Save();
}

/// <summary>
/// Handles the Executed event of the DealNewGameCommand control.
/// </summary>
/// <param name="sender">The source of the event.</param>
/// <param name="args">The <see
/// cref="Apex.MVVM.CommandEventArgs">
/// instance containing the event data.</param>
void SpiderDealNewGameCommand_Executed(object sender, CommandEventArgs args)
{
    //  If we've made any moves, update the stats.
    if (SpiderSolitaireViewModel.Moves > 0)
        SpiderSolitaireStatistics.UpdateStatistics(SpiderSolitaireViewModel);
    Save();
}

/// <summary>
/// Handles the Executed event of the GoToCasinoCommand control.
/// </summary>
/// <param name="sender">The source of the event.</param>
/// <param name="args">The <see
/// cref="Apex.MVVM.CommandEventArgs"> instance
/// containing the event data.</param>
void GoToCasinoCommand_Executed(object sender, CommandEventArgs args)
{
    GoToCasinoCommand.DoExecute(null);
}

就是这样——Casino View Model 完成了。我们现在可以做 Casino View 了。向 SolitaireGames 添加一个名为 CasinoView 的用户控件。

<UserControl x:Class="SolitaireGames.Casino.CasinoView"
 xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
 xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
 xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
 xmlns:solitaireGames="clr-namespace:SolitaireGames"
 xmlns:local="clr-namespace:SolitaireGames.Casino"
 xmlns:apexControls="clr-namespace:Apex.Controls;assembly=Apex"
 xmlns:klondikeSolitaire="clr-namespace:SolitaireGames.KlondikeSolitaire"
 xmlns:spiderSolitaire="clr-namespace:SolitaireGames.SpiderSolitaire"
 xmlns:apexCommands="clr-namespace:Apex.Commands;assembly=Apex"
 mc:Ignorable="d" 
 x:Name="casinoViewModel"
 d:DesignHeight="300" d:DesignWidth="300"
 DataContext="{Binding CasinoViewModel, ElementName=casinoViewModel}">

<!-- The main resource dictionary. -->
<UserControl.Resources>
<ResourceDictionary 
  Source="/SolitaireGames;component/Resources/
          SolitaireGamesResourceDictionary.xaml" />
</UserControl.Resources>

到目前为止一切顺利,只有命名空间、数据上下文和字典。

<!-- Main container. -->
<Grid>

<!-- Background image (the baize). -->
<Image Source="/SolitaireGames;component/Resources/Backgrounds/Background.jpg" 
          Stretch="Fill" />

<!-- The main pivot control. -->
<apexControls:PivotControl x:Name="mainPivot" ShowPivotHeadings="False">

现在我们有一个网格来容纳所有内容,一张毛毡图片(您可以从示例项目中获取),以及一个用于容纳游戏、赌场和设置的枢轴控件。

枢轴控件

Apex 包含一个名为 PivotControl 的控件,它可以容纳一组 PivotItem。这与 WP7 应用程序中看到的枢轴控件非常相似。如果 ShowPivotHeadings 设置为 true,我们会在控件顶部看到标题,允许我们在项目之间移动;但是,在这个应用程序中,我们使用其他地方的按钮来移动。

如果有人觉得 PivotControl 有用,那么我将在另一篇文章中详细介绍它。

现在是第一个枢轴项

<!-- Klondike Solitaire is the first pivot item. -->
<apexControls:PivotItem Title="Klondike">

<klondikeSolitaire:KlondikeSolitaireView 
 x:Name="klondikeSolitaireView"
 ViewModel="{Binding KlondikeSolitaireViewModel}" />

</apexControls:PivotItem>

第一个枢轴项是一个 KlondikeSolitaireView,它绑定到赌场的克朗代克纸牌 ViewModel。

<!-- The casino is the next pivot item. -->
<apexControls:PivotItem Title="Casino" IsInitialPage="True" >
<!-- The main container for the casino. -->
<apexControls:ApexGrid Rows="Auto,Auto,*,Auto">
<!-- The title of the game. -->
<TextBlock 
 Grid.Row="0" FontSize="34" HorizontalAlignment="Center" 
 Foreground="#99FFFFFF" Text="Solitaire" />
<!-- The container for the statistics. -->
<apexControls:ApexGrid Grid.Row="1" 
      Rows="Auto" Columns="*,Auto,Auto,*">
<!-- Klondike/Spider Solitaire Statistics. -->
<local:StatisticsView
 Width="300"
 Grid.Column="1" Grid.Row="0" 
 GameStatistics="{Binding KlondikeSolitaireStatistics}"
 apexCommands:ExtendedCommands.LeftClickCommand=
   "{Binding GoToKlondikeSolitaireCommand}"
 Cursor="Hand" />
<local:StatisticsView
 Width="300"
 Grid.Column="2" Grid.Row="0" 
 GameStatistics="{Binding SpiderSolitaireStatistics}" 
 apexCommands:ExtendedCommands.LeftClickCommand=
   "{Binding GoToSpiderSolitaireCommand}"
 Cursor="Hand" />

</apexControls:ApexGrid>
<!-- The casino commands. -->
<apexControls:ApexGrid
   Grid.Row="4" Columns="*,Auto,Auto,Auto,*">

<Button 
   Grid.Column="1" 
   Style="{StaticResource CasinoButtonStyle}" Width="120" 
   Margin="4" Content="Play Klondike" 
   Command="{Binding GoToKlondikeSolitaireCommand}"  />
<Button 
   Grid.Column="2" Style="{StaticResource CasinoButtonStyle}" 
   Width="120" Margin="4" Content="Settings" 
   Command="{Binding SettingsCommand}"  />
<Button 
   Grid.Column="3" Style="{StaticResource CasinoButtonStyle}" 
   Width="120" Margin="4" Content="Play Spider" 
   Command="{Binding GoToSpiderSolitaireCommand}"  />
</apexControls:ApexGrid>

</apexControls:ApexGrid>
</apexControls:PivotItem>

下一个枢轴项就是赌场本身。它显示一个标题,两个 StatisticsView(我们稍后会看到),以及一些用于从一个页面跳转到另一个页面的命令。现在我们有蜘蛛纸牌了。

<!-- The spider solitaire pivot item. -->
<apexControls:PivotItem Title="Spider">
<spiderSolitaire:SpiderSolitaireView
x:Name="spiderSolitaireView"
ViewModel="{Binding SpiderSolitaireViewModel}" /> 
</apexControls:PivotItem>

同样相当简单。最后一个枢轴项是一个设置页面,我们在其中绑定到克朗代克抽牌模式或赌场牌组样式等内容。

<!-- The settings pivot item.  -->
<apexControls:PivotItem Title="Settings">

<!-- The main container for the settings. -->
<apexControls:ApexGrid Rows="Auto,Auto,*,Auto">

<!-- The title. -->
<TextBlock 
   Grid.Row="0" FontSize="34" HorizontalAlignment="Center" 
   Foreground="#99FFFFFF" Text="Settings" />

<!-- The container for the statistics. -->
<apexControls:PaddedGrid Grid.Row="1" Columns="*,*" 
   Width="500" Padding="4"
   Rows="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto">
<TextBlock 
   Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2"
   Text="Solitaire" FontWeight="Bold" 
   HorizontalAlignment="Center" 
   Style="{StaticResource CasinoTextStyle}" />

<TextBlock 
   Grid.Row="1" Grid.Column="0" Text="Deck Style"
   HorizontalAlignment="Right" 
   Style="{StaticResource CasinoTextStyle}" />

<ComboBox
   Grid.Row="1" Grid.Column="1" 
   SelectedItem="{Binding DeckFolder}"
   ItemsSource="{Binding DeckFolders}" />

<TextBlock
   Grid.Row="2" Grid.Column="1" 
   Style="{StaticResource CasinoTextStyle}"
   FontSize="12" Text="Requires Restart" />

<TextBlock 
   Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="2"
   Text="Klondike Solitaire" FontWeight="Bold" 
   HorizontalAlignment="Center" 
   Style="{StaticResource CasinoTextStyle}" />

<TextBlock 
   Grid.Row="4" Grid.Column="0" Text="Draw Mode"
   HorizontalAlignment="Right" 
   Style="{StaticResource CasinoTextStyle}" />

<ComboBox
   Grid.Row="4" Grid.Column="1" 
   SelectedItem="{Binding KlondikeSolitaireViewModel.DrawMode}"
   ItemsSource="{Binding Source={StaticResource DrawModeValues}}" />

<TextBlock 
   Grid.Row="5" Grid.Column="0" Text="Statistics"
   HorizontalAlignment="Right" 
   Style="{StaticResource CasinoTextStyle}" />

<Button
   Grid.Row="5" Grid.Column="1" 
   Content="Reset" HorizontalAlignment="Left"
   Width="80" Style="{StaticResource CasinoButtonStyle}" 
   Command="{Binding KlondikeSolitaireStatistics.ResetCommand}" />

<TextBlock 
   Grid.Row="6" Grid.Column="0" Grid.ColumnSpan="2"
   Text="Spider Solitaire" FontWeight="Bold" 
   HorizontalAlignment="Center" 
   Style="{StaticResource CasinoTextStyle}" />

<TextBlock 
   Grid.Row="7" Grid.Column="0" Text="Difficulty"
   HorizontalAlignment="Right" 
   Style="{StaticResource CasinoTextStyle}" />

<ComboBox
   Grid.Row="7" Grid.Column="1" 
   SelectedItem="{Binding SpiderSolitaireViewModel.Difficulty}"
   ItemsSource="{Binding Source={StaticResource DifficultyValues}}" />

<TextBlock 
   Grid.Row="8" Grid.Column="0" Text="Statistics"
   HorizontalAlignment="Right" 
   Style="{StaticResource CasinoTextStyle}" />

<Button
   Grid.Row="8" Grid.Column="1" 
   Content="Reset" HorizontalAlignment="Left"
   Width="80" Style="{StaticResource CasinoButtonStyle}" 
   Command="{Binding SpiderSolitaireStatistics.ResetCommand}" />

</apexControls:PaddedGrid>

<Button 
   Grid.Row="3" Style="{StaticResource CasinoButtonStyle}" 
   Width="120" Margin="4" Content="Casino" 
   Command="{Binding GoToCasinoCommand}"  />

</apexControls:ApexGrid>
</apexControls:PivotItem>
</apexControls:PivotControl>
</Grid>
</UserControl>

视图就到这里。现在是代码隐藏。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
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.Navigation;
using System.Windows.Shapes;

namespace SolitaireGames.Casino
{
    /// <summary>
    /// Interaction logic for CasinoView.xaml
    /// </summary>
    public partial class CasinoView : UserControl
    {
        /// <summary>
        /// Initializes a new instance of the CasinoView class.
        /// </summary>
        public CasinoView()
        {
            InitializeComponent();
        }

        /// <summary>
        /// Handles the Executed event
        /// of the GoToKlondikeSolitaireCommand control.
        /// </summary>
        /// <param name="sender">The source of the event.</param>
        /// <param name="args">The Apex.MVVM.CommandEventArgs
        /// instance containing the event data.</param>
        void GoToKlondikeSolitaireCommand_Executed(object sender, 
             Apex.MVVM.CommandEventArgs args)
        {
            mainPivot.SelectedPivotItem = mainPivot.PivotItems[0];
        }

        /// <summary>
        /// Handles the Executed event
        /// of the GoToSpiderSolitaireCommsand control.
        /// </summary>
        /// <param name="sender">The source of the event.</param>
        /// <param name="args">The Apex.MVVM.CommandEventArgs
        ///    instance containing the event data.</param>
        void GoToSpiderSolitaireCommsand_Executed(object sender, 
             Apex.MVVM.CommandEventArgs args)
        {
            mainPivot.SelectedPivotItem = mainPivot.PivotItems[2];
        }

        /// <summary>
        /// Handles the Executed event of the GoToCasinoCommand control.
        /// </summary>
        /// <param name="sender">The source of the event.</param>
        /// <param name="args">The <see
        ///   cref="Apex.MVVM.CommandEventArgs">
        ///   instance containing the event data.</param>
        void GoToCasinoCommand_Executed(object sender, 
             Apex.MVVM.CommandEventArgs args)
        {
            mainPivot.SelectedPivotItem = mainPivot.PivotItems[1];
        }

        /// <summary>
        /// Handles the Executed event of the SettingsCommand control.
        /// </summary>
        /// <param name="sender">The source of the event.</param>
        /// <param name="args">The <see
        ///    cref="Apex.MVVM.CommandEventArgs">
        ///    instance containing the event data.</param>
        private void SettingsCommand_Executed(object sender, 
                     Apex.MVVM.CommandEventArgs args)
        {
            mainPivot.SelectedPivotItem = mainPivot.PivotItems[3];
        }

        /// <summary>
        /// CasinoViewModel dependency property.
        /// </summary>
        private static readonly DependencyProperty CasinoViewModelProperty =
          DependencyProperty.Register("CasinoViewModel", 
          typeof(CasinoViewModel), typeof(CasinoView),
          new PropertyMetadata(null, 
          new PropertyChangedCallback(OnCasinoViewModelChanged)));

        /// <summary>
        /// Gets or sets the casino view model.
        /// </summary>
        /// <value>The casino view model.</value>
        public CasinoViewModel CasinoViewModel
        {
            get { return (CasinoViewModel)GetValue(CasinoViewModelProperty); }
            set { SetValue(CasinoViewModelProperty, value); }
        }

        /// <summary>
        /// Called when casino view model changed.
        /// </summary>
        /// <param name="o">The o.</param>
        /// <param name="args">The
        /// System.Windows.DependencyPropertyChangedEventArgs
        /// instance containing the event data.</param>
        private static void OnCasinoViewModelChanged(DependencyObject o, 
                DependencyPropertyChangedEventArgs args)
        {
            CasinoView me = o as CasinoView;

            //  Listen for events.
            me.CasinoViewModel.GoToCasinoCommand.Executed += 
              new Apex.MVVM.CommandEventHandler(me.GoToCasinoCommand_Executed);
            me.CasinoViewModel.GoToSpiderSolitaireCommand.Executed += 
              new Apex.MVVM.CommandEventHandler(
              me.GoToSpiderSolitaireCommsand_Executed);
            me.CasinoViewModel.GoToKlondikeSolitaireCommand.Executed += 
              new Apex.MVVM.CommandEventHandler(
              me.GoToKlondikeSolitaireCommand_Executed);
            me.CasinoViewModel.SettingsCommand.Executed += 
              new Apex.MVVM.CommandEventHandler(me.SettingsCommand_Executed);
        }                
    }
}

这没什么大不了的——CasinoViewModel 是一个依赖属性。当它被设置时,我们监听“GoTo...”命令,当它们被触发时,只需将枢轴控件的选择移动到正确的项目。

赌场的最后一件事是 StatisticsView,这是一个小型的用户控件,显示 StatisticsViewModel 的详细信息。将一个名为 StatisticsView 的用户控件添加到 Casino 文件夹中,这是代码隐藏。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
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.Navigation;
using System.Windows.Shapes;

namespace SolitaireGames.Casino
{
    /// <summary>
    /// Interaction logic for StatisticsView.xaml
    /// </summary>
    public partial class StatisticsView : UserControl
    {
        /// <summary>
        /// Initializes a new instance of the StatisticsView class.
        /// </summary>
        public StatisticsView()
        {
            InitializeComponent();
        }
        
        /// <summary>
        /// The statistics view model.
        /// </summary>
        private static readonly DependencyProperty GameStatisticsProperty =
          DependencyProperty.Register("GameStatistics", 
          typeof(GameStatistics), typeof(StatisticsView),
          new PropertyMetadata(null));

        /// <summary>
        /// Gets or sets the game statistics.
        /// </summary>
        /// <value>The game statistics.</value>
        public GameStatistics GameStatistics
        {
            get { return (GameStatistics)GetValue(GameStatisticsProperty); }
            set { SetValue(GameStatisticsProperty, value); }
        }    
    }
}

只有一个依赖属性——统计视图模型(它允许我们将视图模型绑定到 CasinoViewModel 子项)。最后是 XAML。

<UserControl x:Class="SolitaireGames.Casino.StatisticsView"
 xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
 xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
 xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
 xmlns:apexControls="clr-namespace:Apex.Controls;assembly=Apex"
 mc:Ignorable="d" 
 d:DesignHeight="300" d:DesignWidth="300"
 x:Name="statisticsView">

<!-- Point to the main resource dictionary for the assembly. -->
<UserControl.Resources>
<ResourceDictionary 
  Source="/SolitaireGames;component/Resources/
          SolitaireGamesResourceDictionary.xaml" />
</UserControl.Resources>

<Border Padding="10" Margin="10" 
 BorderBrush="#99FFFFFF" BorderThickness="6" CornerRadius="15"
 DataContext="{Binding GameStatistics, ElementName=statisticsView}">

<apexControls:PaddedGrid 
   Rows="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" 
   Columns="3*,*" 
   TextBlock.Foreground="#99FFFFFF" 
   TextBlock.FontSize="13">

<!-- The Game Title. -->
<TextBlock 
   Grid.Row="0" Grid.Column="0" 
   Grid.ColumnSpan="2" HorizontalAlignment="Center"
   Text="{Binding GameName}" FontSize="24" />

<!-- The game stats. -->
<TextBlock Grid.Row="1" Grid.Column="0" Text="Games Played" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding GamesPlayed}" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Games Won" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding GamesWon}" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Games Lost" />
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding GamesLost}" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="Current Streak" />
<TextBlock Grid.Row="4" Grid.Column="1" Text="{Binding CurrentStreak}" />
<TextBlock Grid.Row="5" Grid.Column="0" Text="Highest Winning Streak" />
<TextBlock Grid.Row="5" Grid.Column="1" Text="{Binding HighestWinningStreak}" />
<TextBlock Grid.Row="6" Grid.Column="0" Text="Highest Losing Streak" />
<TextBlock Grid.Row="6" Grid.Column="1" Text="{Binding HighestLosingStreak}" />
<TextBlock Grid.Row="7" Grid.Column="0" Text="Highest Score" />
<TextBlock Grid.Row="7" Grid.Column="1" Text="{Binding HighestScore}" />
<TextBlock Grid.Row="8" Grid.Column="0" Text="Cumulative Score" />
<TextBlock Grid.Row="8" Grid.Column="1" Text="{Binding CumulativeScore}" />
<TextBlock Grid.Row="9" Grid.Column="0" Text="Average Score" />
<TextBlock Grid.Row="9" Grid.Column="1" Text="{Binding AverageScore}" />
<TextBlock Grid.Row="10" Grid.Column="0" Text="Cumulative Time" />
<TextBlock Grid.Row="10" Grid.Column="1" 
  Text="{Binding CumulativeGameTime, 
        Converter={StaticResource TimeSpanToShortStringConverter}}" />
<TextBlock Grid.Row="11" Grid.Column="0" Text="Average Time" />
<TextBlock Grid.Row="11" Grid.Column="1" 
   Text="{Binding AverageGameTime, Converter={StaticResource 
         TimeSpanToShortStringConverter}}" />
</apexControls:PaddedGrid>
</Border> 
</UserControl>

如我们所见,这个控件只是在一个圆角边框中显示游戏统计数据。

步骤 7:纸牌应用程序

现在转到 Solitaire 项目并打开 MainWindow.xaml,确保您已引用 Apex 和 SolitaireGames,并添加一个 CasinoView,如下所示。

<Window x:Class="Solitaire.MainWindow"
 xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
 xmlns:casino="clr-namespace:SolitaireGames.Casino;assembly=SolitaireGames"
 Title="Solitaire" Height="400" Width="650" 
 Icon="/Solitaire;component/Solitaire.ico">

<!-- All we need is a casino view. -->
<casino:CasinoView x:Name="casinoView" />

</Window>

而代码隐藏只是加载 ViewModel(并在我们关闭时保存它)。

/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();

        casinoView.CasinoViewModel = CasinoViewModel.Load();
        casinoView.CasinoViewModel.Initialise();

        Closing += 
         new System.ComponentModel.CancelEventHandler(MainWindow_Closing);
    }

    void MainWindow_Closing(object sender, 
         System.ComponentModel.CancelEventArgs e)
    {
        casinoView.CasinoViewModel.Save();
    }
}

我们添加的最后一样东西是一个名为 Solitaire.ico 的图标,可在下载中获取。

按 F5 尽情享受吧!

附录 1:牌堆控件

牌堆控制是一个相当复杂的家伙,所以我把它作为附录。它是一个项目控制,有一组属性传递给牌堆面板进行布局。这是牌堆面板。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows.Controls;
using System.Windows;

namespace SolitaireGames
{
    /// <summary>
    /// The offset mode - how we offset individual cards in a stack.
    /// </summary>
    public enum OffsetMode
    {
        /// <summary>
        /// Offset every card.
        /// </summary>
        EveryCard,

        /// <summary>
        /// Offset every Nth card.
        /// </summary>
        EveryNthCard,

        /// <summary>
        /// Offset only the top N cards.
        /// </summary>
        TopNCards,

        /// <summary>
        /// Offset only the bottom N cards.
        /// </summary>
        BottomNCards,

        /// <summary>
        /// Use the offset values specified in the playing card class (see
        /// PlayingCard.FaceDownOffset and PlayingCard.FaceUpOffset).
        /// </summary>
        UseCardValues
    }

    /// <summary>
    /// A panel for laying out cards.
    /// </summary>
    public class CardStackPanel : StackPanel
    {
        /// <summary>
        /// Infinite size, useful later.
        /// </summary>
        private readonly Size infiniteSpace = 
                new Size(Double.MaxValue, Double.MaxValue);

        /// <summary>
        /// Measures the child elements
        /// of a System.Windows.Controls.StackPanel
        /// in anticipation of arranging them during the
        /// System.Windows.Controls.StackPanel.
        //     ArrangeOverride(System.Windows.Size pass.
        /// </summary>
        /// <param name="constraint">An upper limit
        /// System.Windows.Size that should not be exceeded.</param>
        /// <returns>
        /// The System.Windows.Size that represents
        /// the desired size of the element.
        /// </returns>
        protected override Size MeasureOverride(Size constraint)
        {
            //  Keep track of the overall size required.
            Size resultSize = new Size(0, 0);

            //  Get the offsets that each element will need.
            List<size> offsets = CalculateOffsets();

            //  Calculate the total.
            double totalX = (from o in offsets select o.Width).Sum();
            double totalY = (from o in offsets select o.Height).Sum();

            //  Measure each child (always needed, even if we don't use
            //  the measurement!)
            foreach(UIElement child in Children)
            {
                //  Measure the child against infinite space.
                child.Measure(infiniteSpace);
            }

            //  Add the size of the last element.
            if (LastChild != null)
            {
                //  Add the size.
                totalX += LastChild.DesiredSize.Width;
                totalY += LastChild.DesiredSize.Height;
            }
                        
            return new Size(totalX, totalY);
        }

        /// <summary>
        /// When overridden in a derived class, positions child
        /// elements and determines a size for
        /// a System.Windows.FrameworkElement derived class.
        /// </summary>
        /// <param name="finalSize">The final
        /// area within the parent that this element should
        /// use to arrange itself and its children.</param>
        /// <returns>The actual size used.</returns>
        protected override Size ArrangeOverride(Size finalSize)
        {
            double x = 0, y = 0;
            int n = 0;
            int total = Children.Count;

            //  Get the offsets that each element will need.
            List<size> offsets = CalculateOffsets();
            
            //  If we're going to pass the bounds, deal with it.
            if ((ActualWidth > 0 && finalSize.Width > ActualWidth) || 
                (ActualHeight > 0 && finalSize.Height > ActualHeight))
            {
                //  Work out the amount we have to remove from the offsets.
                double overrunX = finalSize.Width - ActualWidth;
                double overrunY = finalSize.Height - ActualHeight;

                //  Now as a per-offset.
                double dx = overrunX / offsets.Count;
                double dy = overrunY / offsets.Count;

                //  Now nudge each offset.
                for (int i = 0; i < offsets.Count; i++)
                {
                    offsets[i] = new Size(Math.Max(0, offsets[i].Width - dx), 
                                 Math.Max(0, offsets[i].Height - dy));
                }

                //  Make sure the final size isn't increased past what we can handle.
                finalSize.Width -= overrunX;
                finalSize.Height -= overrunY;
            }

            //  Arrange each child.
            foreach (UIElement child in Children)
            {
                //  Get the card. If we don't have one, skip.
                PlayingCard card = ((FrameworkElement)child).DataContext as PlayingCard;
                if (card == null)
                    continue;

                //  Arrange the child at x,y (the first will be at 0,0)
                child.Arrange(new Rect(x, y, child.DesiredSize.Width, 
                              child.DesiredSize.Height));

                //  Update the offset.
                x += offsets[n].Width;
                y += offsets[n].Height;

                //  Increment.
                n++;
            }

            return finalSize;
        }

        /// <summary>
        /// Calculates the offsets.
        /// </summary>
        /// <returns>
        private List<size> CalculateOffsets()
        {
            //  Calculate the offsets on a card by card basis.
            List<size> offsets = new List<size>();

            int n = 0;
            int total = Children.Count;

            //  Go through each card.
            foreach (UIElement child in Children)
            {
                //  Get the card. If we don't have one, skip.
                PlayingCard card = ((FrameworkElement)child).DataContext as PlayingCard;
                if (card == null)
                    continue;

                //  The amount we'll offset by.
                double faceDownOffset = 0;
                double faceUpOffset = 0;

                //  We are now going to offset only if the offset mode is appropriate.
                switch (OffsetMode)
                {
                    case OffsetMode.EveryCard:
                        //  Offset every card.
                        faceDownOffset = FaceDownOffset;
                        faceUpOffset = FaceUpOffset;
                        break;
                    case OffsetMode.EveryNthCard:
                        //  Offset only if n Mod N is zero.
                        if (((n + 1) % NValue) == 0)
                        {
                            faceDownOffset = FaceDownOffset;
                            faceUpOffset = FaceUpOffset;
                        }
                        break;
                    case OffsetMode.TopNCards:
                        //  Offset only if (Total - N) <= n < Total
                        if ((total - NValue) <= n && n < total)
                        {
                            faceDownOffset = FaceDownOffset;
                            faceUpOffset = FaceUpOffset;
                        }
                        break;
                    case OffsetMode.BottomNCards:
                        //  Offset only if 0 < n < N
                        if (n < NValue)
                        {
                            faceDownOffset = FaceDownOffset;
                            faceUpOffset = FaceUpOffset;
                        }
                        break;
                    case SolitaireGames.OffsetMode.UseCardValues:
                        //  Offset each time by the amount specified in the card object.
                        faceDownOffset = card.FaceDownOffset;
                        faceUpOffset = card.FaceUpOffset;
                        break;
                    default:
                        break;
                }

                n++;

                //  Create the offset as a size.
                Size offset = new Size(0, 0);
                
                //  Offset.
                switch (Orientation)
                {
                    case Orientation.Horizontal:
                        offset.Width = card.IsFaceDown ? faceDownOffset : faceUpOffset;
                        break;
                    case Orientation.Vertical:
                        offset.Height = card.IsFaceDown ? faceDownOffset : faceUpOffset;
                        break;
                    default:
                        break;
                }

                //  Add to the list.
                offsets.Add(offset);
            }

            return offsets;
        }

        /// <summary>
        /// Gets the last child.
        /// </summary>
        /// <value>The last child.</value>
        private UIElement LastChild
        {
            get { return Children.Count > 0 ? Children[Children.Count - 1] : null; }
        }

        /// <summary>
        /// Face down offset.
        /// </summary>
        private static readonly DependencyProperty FaceDownOffsetProperty =
          DependencyProperty.Register("FaceDownOffset", 
          typeof(double), typeof(CardStackPanel),
          new FrameworkPropertyMetadata(5.0, 
          FrameworkPropertyMetadataOptions.AffectsMeasure | 
          FrameworkPropertyMetadataOptions.AffectsArrange));

        /// <summary>
        /// Gets or sets the face down offset.
        /// </summary>
        /// <value>The face down offset.</value>
        public double FaceDownOffset
        {
            get { return (double)GetValue(FaceDownOffsetProperty); }
            set { SetValue(FaceDownOffsetProperty, value); }
        }

        /// <summary>
        /// Face up offset.
        /// </summary>
        private static readonly DependencyProperty FaceUpOffsetProperty =
          DependencyProperty.Register("FaceUpOffset", 
          typeof(double), typeof(CardStackPanel),
          new FrameworkPropertyMetadata(5.0, 
          FrameworkPropertyMetadataOptions.AffectsMeasure | 
          FrameworkPropertyMetadataOptions.AffectsArrange));

        /// <summary>
        /// Gets or sets the face up offset.
        /// </summary>
        /// <value>The face up offset.</value>
        public double FaceUpOffset
        {
            get { return (double)GetValue(FaceUpOffsetProperty); }
            set { SetValue(FaceUpOffsetProperty, value); }
        }

        /// <summary>
        /// The offset mode.
        /// </summary>
        private static readonly DependencyProperty OffsetModeProperty =
          DependencyProperty.Register("OffsetMode", 
          typeof(OffsetMode), typeof(CardStackPanel),
          new PropertyMetadata(OffsetMode.EveryCard));

        /// <summary>
        /// Gets or sets the offset mode.
        /// </summary>
        /// <value>The offset mode.</value>
        public OffsetMode OffsetMode
        {
            get { return (OffsetMode)GetValue(OffsetModeProperty); }
            set { SetValue(OffsetModeProperty, value); }
        }

        /// <summary>
        /// The NValue, used for some modes.
        /// </summary>
        private static readonly DependencyProperty NValueProperty =
          DependencyProperty.Register("NValue", 
          typeof(int), typeof(CardStackPanel),
          new PropertyMetadata(1));

        /// <summary>
        /// Gets or sets the N value.
        /// </summary>
        /// <value>The N value.</value>
        public int NValue
        {
            get { return (int)GetValue(NValueProperty); }
            set { SetValue(NValueProperty, value); }
        }
    }
}

CardStackControl 只是复制了关键属性,并通过 SolitaireGamesResourcesDictionary.xaml 文件中的模板将它们传递给其子 CardStackPanelCardStackPanel 的设计理念是它几乎可以完成我们所需的所有牌布局操作。它还会确保在必要时将牌堆“挤压”到可用空间中。

附录 2:拖放

Apex 中的拖放本身就是一个完整的话题;如果有人觉得所使用的方法在其他应用程序中会有用,请告诉我,我将把它写成一篇完整的文章。

纸牌与增强现实

我收到了一封来自 Rupam Das (https://codeproject.org.cn/script/Membership/View.aspx?mid=8114613) 的非常有趣的邮件,他制作了这个项目的增强现实版本!在他的应用程序中,您可以使用网络摄像头通过手势实际玩游戏,通过手势拿起纸牌。其他手势,例如竖起大拇指和向下大拇指,都绑定到游戏中的命令——这是一个屏幕截图。

 

该项目名为 GesCard。请点击此链接观看 YouTube 视频 https://www.youtube.com/watch?v=wCOjuPdBooI。感谢 rupam 与我联系并分享了这个非常酷的代码!

最后思考

这个应用程序还有很多可以改进的地方。

  • 动画 
  • 音效
  • 不同的计分模型

这只是其中几个,但它已经变得太庞大,无法写成一篇像样的文章,所以我现在上传它;请随意玩它,修改它,并提出改进建议。

我按照文章的顺序将整个项目从一个解决方案转录到另一个解决方案,以确保所有内容都详细说明,因此可能存在错误,如果您发现任何错误,请告诉我!

我是在尺骨骨折的情况下写完这整篇文章的,所以如果出现拼写错误或其他奇怪之处,敬请谅解!

Apex

Apex 的最基本形式在 apex.aspx 上有所介绍,但最新版本可在以下网址获取:http://apex.codeplex.com/

© . All rights reserved.