C#入门经典(第8版)阅读记录

目录

第I部分 C#语言

第10章 定义类成员

实现卡牌类库

实践

1、创建新的类库项目CardLib

2、从项目中删除默认类Class1.cs

3、创建代表花色的枚举类Suit.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace CardLib
{
    public enum Suit
    {
        Club,
        Diamond,
        Heart,
        Spade
    }
}

4、创建代表卡牌面值的枚举类Rank.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace CardLib
{
    public enum Rank
    {
        Ace = 1,
        Deuce,
        Three,
        Four,
        Five,
        Six,
        Seven,
        Eight,
        Nine,
        Ten,
        Jack,
        Queen,
        King
    }
}

5、创建代表卡牌的类Card.cs。重写的ToString()方法将存储的枚举值的字符串表示形式写入返回的字符串,并由自定义构造函数初始化suit和rank字段的值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
using System;
using System.Collections.Generic;
using System.Text;

namespace CardLib
{
    public class Card
    {
        public readonly Suit suit;
        public readonly Rank rank;
        public Card(Suit newSuit, Rank newRank)
        {
            suit = newSuit;
            rank = newRank;
        }
        private Card() { }
        public override string ToString() => "The " + rank + " of " + suit + "s";
    }
}

6、创建代表牌堆的类Deck.cs。首先,实现构造函数,该构造函数仅在cards字段中创建并分配52张卡。您遍历两个枚举的所有组合,并使用每个枚举创建一个卡。这将导致cards最初包含卡片的有序列表:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace CardLib
{
    public class Deck
    {
        private Card[] cards;
        public Deck()
        {
            cards = new Card[52];
            for (int suitVal = 0; suitVal < 4; suitVal++)
            {
                for (int rankVal = 1; rankVal < 14; rankVal++)
                {
                    cards[suitVal * 13 + rankVal - 1] = new Card((Suit)suitVal, (Rank)rankVal);
                }
            }
        }
    }
}

7、接下来,实现GetCard()方法,该方法将返回带有所请求索引的Card对象,或者引发如先前所示的异常:

1
2
3
4
5
6
7
8
9
public Card GetCard(int cardNum)
{
    if (cardNum >= 0 && cardNum <= 51)
        return cards[cardNum];
    else
        throw
            (new System.ArgumentOutOfRangeException("cardNum", cardNum,
            "Value must be between 0 and 51."));
}

8、最后,实现Shuffle()方法。此方法通过创建临时卡牌数组newDeck并将卡从现有cards数组随机复制到该数组中而起洗牌作用。该函数的主体是一个从0到51的循环。在每个循环中,您都使用.NET Framework中的System.Random类的实例sourceGen生成一个介于0和51之间的随机数。实例化后,此类的对象使用Next(X)方法生成一个介于0和X之间的随机数。当您拥有随机数时,只需将其用作临时数组中Card对象的索引,即可从cards数组中复制卡牌。

为了保留分配的卡的记录,您还具有一个布尔变量数组assigned,并在复制每张卡时将它们分配为true。生成随机数时,请对照此数组检查是否已将卡复制到该随机数指定的临时数组中的位置。如果是这样,您只需生成另一个。

这不是最有效的处理方式,因为在找到可以复制卡的空闲插槽之前会生成许多随机数。但是,它很有效,非常简单,而且C#代码执行得如此之快,几乎不会引起延迟。

此方法的最后一行使用System.Array类的CopyTo()方法(在创建数组时使用)将newDeck中的每个卡复制回cards中。这意味着您将在同一Card对象中使用同一个cards对象,而不是创建任何新实例。如果您改用cards = newDeck,则将用cards引用的对象实例替换为另一个实例。如果其他地方的代码保留了对原始cards实例的引用,这可能会引起问题-不会被洗牌!代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public void Shuffle()
{
    Card[] newDeck = new Card[52];
    bool[] assigned = new bool[52];
    Random sourceGen = new Random();
    for (int i = 0; i < 52; i++)
    {
        int destCard = 0;
        bool foundCard = false;
        while (foundCard == false)
        {
            destCard = sourceGen.Next(52);
            if (assigned[destCard] == false)
                foundCard = true;
        }
        assigned[destCard] = true;
        newDeck[destCard] = cards[i];
    }
    newDeck.CopyTo(cards, 0);
}

卡牌类库的客户端应用程序

实践

1、右键单击CardLib项目解决方案,选在添加->新建项目,创建一个新的控制台应用程序CardClient。

2、在CardClient的引用中添加对CardLib的引用。

3、在CardClient的Program.cs中填入如下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using static System.Console;
using CardLib;

namespace CardClient
{
    class Program
    {
        static void Main(string[] args)
        {
            Deck myDeck = new Deck();
            myDeck.Shuffle();
            for (int i = 0; i < 52; i++)
            {
                Card tempCard = myDeck.GetCard(i);
                WriteLine(tempCard.ToString());
            }
            ReadKey();
        }
    }
}

4、设置CardClient为启动项,并运行:

第11章 集合、比较和转换

给CardLib添加Cards集合

实践

1、在CardLib项目中添加Cards类,表示Card对象的一个定制集合:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace CardLib
{
    public class Cards : CollectionBase
    {
        public void Add(Card newCard) => List.Add(newCard);
        public void Remove(Card oldCard) => List.Remove(oldCard);
        public Card this[int cardIndex]
        {
            get { return (Card)List[cardIndex]; }
            set { List[cardIndex] = value; }
        }
        /// <summary>
        /// 将卡实例复制到另一个Card实例中的实用方法 -
        /// 在Deck.Shuffle()中使用。此实现假定源集合和目标集合的大小相同。
        /// </summary>
        public void CopyTo(Cards targetCards)
        {
            for (int index = 0; index < this.Count; index++)
            {
                targetCards[index] = this[index];
            }
        }
        /// <summary>
        /// 检查卡片集合是否包含特定卡片。
        /// 这将为集合调用ArrayList的Contains()方法,您可以通过InnerList属性访问该方法。
        /// </summary>
        public bool Contains(Card card) => InnerList.Contains(card);
    }
}

2、修改Deck.cs以利用集合类Cards

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace CardLib
{
    public class Deck
    {
        private Cards cards = new Cards();
        public Deck()
        {
            for (int suitVal = 0; suitVal < 4; suitVal++)
            {
                for (int rankVal = 1; rankVal < 14; rankVal++)
                {
                    cards.Add(new Card((Suit)suitVal, (Rank)rankVal));
                }
            }
        }
        public Card GetCard(int cardNum)
        {
            if (cardNum >= 0 && cardNum <= 51)
                return cards[cardNum];
            else
                throw
                    (new System.ArgumentOutOfRangeException("cardNum", cardNum,
                    "Value must be between 0 and 51."));
        }

        public void Shuffle()
        {
            Cards newDeck = new Cards();
            bool[] assigned = new bool[52];
            Random sourceGen = new Random();
            for (int i = 0; i < 52; i++)
            {
                int sourceCard = 0;
                bool foundCard = false;
                while (foundCard == false)
                {
                    sourceCard = sourceGen.Next(52);
                    if (assigned[sourceCard] == false)
                        foundCard = true;
                }
                assigned[sourceCard] = true;
                newDeck.Add(cards[sourceCard]);
            }
            newDeck.CopyTo(cards);
        }
    }
}

3、运行程序可以获得与第10章相同的效果。

给CardLib添加深度复制

通过使用ICloneable接口实现复制Card,Cards和Deck对象的功能来将进行深度复制。在某些纸牌游戏中,这可能很有用,在这些游戏中,尽管您可能会想将一个牌堆设置为与另一个牌堆具有相同的卡片顺序,但不一定希望这两个牌堆(Deck)都引用同一组Card对象。

1、在CardLib中为Card类实现克隆功能很简单,因为浅度复制就足够了(作为字段时,Card仅包含值类型数据)。首先对Card类定义进行以下更改:

1
2
3
4
5
// Card.cs
public class Card : ICloneable
{
    public object Clone() => MemberwiseClone();
    ...

在上面的代码中,ICloneable的实现只是一个浅度复制。没有规则确定Clone()方法中应发生的情况,这足以满足您的目的。

2、接下来,在Cards集合类上实现ICloneable。这稍微复杂一点,因为它涉及克隆原始集合中的每个Card对象,因此您需要进行深拷贝:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Cards.cs
public class Cards : CollectionBase, ICloneable
{
    public object Clone()
    {
        Cards newCards = new Cards();
        foreach (Card sourceCard in List)
        {
            newCards.Add((Card)sourceCard.Clone());
        }
        return newCards;
    }
    ...

3、最后,在Deck类上实现ICloneable。请注意此处的一个小问题:CardLib中的Deck类无法修改其中包含的卡,除非将调用Shuffle()洗牌函数。例如,无法将Deck实例修改为具有给定的卡片顺序。要解决此问题,请为Deck类定义一个新的私有构造函数,该构造函数允许在实例化Deck对象时传递特定的Cards集合。 这是在此类中实现克隆的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Deck.cs
public class Deck : ICloneable
{
    public object Clone()
    {
        Deck newDeck = new Deck(cards.Clone() as Cards);
        return newDeck;
    }

    private Deck(Cards newCards) => cards = newCards;
    ...

4、同样,您可以使用一些简单的客户端代码对此进行测试。与以前一样,将代码放在客户端项目的Main()方法中进行测试(您可以在本章在线下载的CardClient\Program.cs中找到此代码):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// CardClient\Program.cs
static void Main(string[] args)
{
    Deck deck1 = new Deck();
    Deck deck2 = (Deck)deck1.Clone();
    WriteLine($"The first card in the original deck is: {deck1.GetCard(0)}");
    WriteLine($"The first card in the cloned deck is: {deck2.GetCard(0)}");
    deck1.Shuffle();
    WriteLine("Original deck shuffled.");
    WriteLine($"The first card in the original deck is: {deck1.GetCard(0)}");
    WriteLine($"The first card in the cloned deck is: {deck2.GetCard(0)}");
    ReadKey();
}

给CardLib添加运算符重载

1、现在,您将再次升级CardLib项目,将操作符重载添加到Card类。首先,您将向Card类添加额外的字段,这些字段允许使用王牌花色(最大的花色)和一个将Ace面值放高的选项。您将这些设置为静态是因为设置它们后,它们将应用于所有Card对象:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/// <summary>
/// 标志王牌用法。如果为true,则表示王牌的价值高于其他西装的卡片。
/// </summary>
public static bool useTrumps = false;
/// <summary>
/// 如果useTrumps为true,则设置王牌的花色为Club。
/// </summary>
public static Suit trump = Suit.Club;
/// <summary>
/// 确定Ace是高于kings还是低于Deuces(2)的标志。
/// </summary>
public static bool isAceHigh = true;

这些规则适用于应用程序中每个Deck中的所有Card对象。不可能有两副纸牌,每张纸牌都包含遵循不同规则的纸牌。不过,对于该类库而言,这很好,因为您可以放心地假设,如果单个应用程序要使用单独的规则,则它可以自己维护这些规则,也许只要切换牌堆就可以设置Card的静态成员。

2、在Deck类中添加一些构造函数以初始化具有独特特征的卡片组:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/// <summary>
/// 非默认构造函数。允许将ace设置为高等级。
/// </summary>
public Deck(bool isAceHigh) : this()
{
    Card.isAceHigh = isAceHigh;
}
/// <summary>
/// 非默认构造函数。允许使用王牌花色。
/// </summary>
public Deck(bool useTrumps, Suit trump) : this()
{
    Card.useTrumps = useTrumps;
    Card.trump = trump;
}
/// <summary>
/// 非默认构造函数。允许将ace设置为高等级,并使用王牌花色。
/// </summary>
public Deck(bool isAceHigh, bool useTrumps, Suit trump) : this()
{
    Card.isAceHigh = isAceHigh;
    Card.useTrumps = useTrumps;
    Card.trump = trump;
}

这些构造函数中都使用了 : this() 语法定义,因此在所有情况下,都会在非默认构造函数之前调用默认构造函数,以初始化Deck

3、在Card类中进行操作符的重载:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
public static bool operator == (Card card1, Card card2) 
    => (card1?.suit == card2?.suit) && (card1?.rank == card2?.rank);

public static bool operator != (Card card1, Card card2) => !(card1 == card2);

public override bool Equals(object card) => this == (Card)card;

public override int GetHashCode() => 13 * (int)suit + (int)rank;

public static bool operator >(Card card1, Card card2)
{
    if (card1.suit == card2.suit)
    {
        if (isAceHigh)
        {
            if (card1.rank == Rank.Ace)
            {
                if (card2.rank == Rank.Ace)
                    return false;
                else
                    return true;
            }
            else
            {
                if (card2.rank == Rank.Ace)
                    return false;
                else
                    return (card1.rank > card2?.rank);
            }
        }
        else
        {
            return (card1.rank > card2.rank);
        }
    }
    else
    {
        if (useTrumps && (card2.suit == Card.trump))
            return false;
        else
            return true;
    }
}

public static bool operator <(Card card1, Card card2) 
    => !(card1 >= card2);

public static bool operator >=(Card card1, Card card2)
{
    if (card1.suit == card2.suit)
    {
        if (isAceHigh)
        {
            if (card1.rank == Rank.Ace)
            {
                return true;
            }
            else
            {
                if (card2.rank == Rank.Ace)
                    return false;
                else
                    return (card1.rank >= card2.rank);
            }
        }
        else
        {
            return (card1.rank >= card2.rank);
        }
    }
    else
    {
        if (useTrumps && (card2.suit == Card.trump))
            return false;
        else
            return true;
    }
}

public static bool operator <=(Card card1, Card card2) => !(card1 > card2);

注意: 在==和>运算符重载方法中实现的**空条件运算符(?.)**在第12章中有更详细的讨论。public static bool operator ==方法的代码段card1?.suit中的?.,在尝试检索card1对象中存储的值之前,会先检查card1对象是否为null。在后面的章节中实现该方法时,这一点很重要。

这里没有什么要注意的,除了>和>=重载运算符的代码可能有点冗长。如果逐步浏览>的代码,则可以看到其工作原理以及为什么需要执行这些步骤。

这里主要分析重载>的代码:

您正在比较两个卡,card1和card2,其中card1被认为是摆在桌子上的第一张卡片。如前所述,这在使用王牌时非常重要,因为即使非王牌的Rank更高,王牌也会击败该非王牌。当然,如果两张卡的花色相同,那么该花色是否为王牌花色就无关紧要了,因此这是您进行的第一个比较:

1
2
3
4
public static bool operator >(Card card1, Card card2)
{
    if (card1.suit == card2.suit)
    {

如果静态isAceHigh标志为true,则您无法直接通过Rank枚举中的值比较卡片的排名,因为在此枚举中Ace的等级值为1,该值小于所有其他Rank的值。故使用以下步骤:

  • 如果第一张牌是一张A,请检查第二张牌是否也是一张A。如果是的话,那么第一张卡就不会击败第二张卡。如果第二张牌不是Ace,则第一张牌获胜:
1
2
3
4
5
6
7
8
9
if (isAceHigh)
{
    if (card1.rank == Rank.Ace)
    {
        if (card2.rank == Rank.Ace)
            return false;
        else
            return true;
    }
  • 如果第一张不是Ace,那么您还需要检查第二张是否是Ace。如果是,则第二张牌获胜。否则,您可以比较Rank值,因为您知道Ace已不影响胜负:
1
2
3
4
5
6
7
8
    else
    {
        if (card2.rank == Rank.Ace)
            return false;
        else
            return (card1.rank > card2?.rank);
    }
}
  • 如果Ace不高,则只需比较等级值即可:
1
2
3
4
else
{
    return (card1.rank > card2.rank);
}

代码的其余部分涉及card1和card2的花色不同的情况。在这里,静态useTrumps标志很重要。如果此标志为true并且card2是王牌,则可以肯定地说出card1不是王牌(因为两张牌的花色不同);并且王牌总是赢,所以card2是较高的牌:

1
2
3
4
else
{
    if (useTrumps && (card2.suit == Card.trump))
        return false;

如果card2不是王牌(或useTrumps为false),则card1获胜,因为它是第一张被放置的牌:

1
2
3
4
        else
            return true;
    }
}

另外还有一个运算符(>=)使用与此类似的代码,而其他运算符非常简单,因此无需对其进行更多详细说明。

4、以下简单的客户端代码将测试这些运算符。只需将其放在客户端项目的Main()方法中进行测试即可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
static void Main(string[] args)
{
    Card.isAceHigh = true;
    WriteLine("Aces are high.");
    Card.useTrumps = true;
    Card.trump = Suit.Club;
    WriteLine("Clubs are trumps.");
    Card card1, card2, card3, card4, card5;
    card1 = new Card(Suit.Club, Rank.Five);
    card2 = new Card(Suit.Club, Rank.Five);
    card3 = new Card(Suit.Club, Rank.Ace);
    card4 = new Card(Suit.Heart, Rank.Ten);
    card5 = new Card(Suit.Diamond, Rank.Ace);
    WriteLine($"{card1.ToString()} == {card2.ToString()} ? {card1 == card2}");
    WriteLine($"{card1.ToString()} != {card3.ToString()} ? {card1 != card3}");
    WriteLine($"{card1.ToString()}.Equals({card4.ToString()}) ? " + $" { card1.Equals(card4)}");
    WriteLine($"Card.Equals({card3.ToString()}, {card4.ToString()}) ? " + $" { Card.Equals(card3, card4)}");
    WriteLine($"{card1.ToString()} > {card2.ToString()} ? {card1 > card2}");
    WriteLine($"{card1.ToString()} <= {card3.ToString()} ? {card1 <= card3}");
    WriteLine($"{card1.ToString()} > {card4.ToString()} ? {card1 > card4}");
    WriteLine($"{card4.ToString()} > {card1.ToString()} ? {card4 > card1}");
    WriteLine($"{card5.ToString()} > {card4.ToString()} ? {card5 > card4}");
    WriteLine($"{card4.ToString()} > {card5.ToString()} ? {card4 > card5}");
    ReadKey();
}

第12章 泛型

修改CardLib以便使用泛型集合类

1、您可以对最近几章中构建的CardLib项目进行一个简单的修改,就是将Cards集合类更改为泛型集合类,从而节省许多行代码。对Cards类定义的必需修改如下:

1
public class Cards : List<Card>, ICloneable { ... }

您还可以删除除ICloneable所需的Clone()和CopyTo()之外的Cards的所有方法,因为List<Card>提供的CopyTo()的版本适用于Card对象数组,而不是Cards集合。Clone()需进行少量修改,因为List<Card>类未定义要使用的List属性(在去掉的CollectionBase类中定义):

1
2
3
4
5
6
7
8
9
public object Clone()
{
    Cards newCards = new Cards();
    foreach (Card sourceCard in this)
    {
        newCards.Add((Card)sourceCard.Clone());
    }
    return newCards;
}

第13章 高级C#技术

给CardLib添加定制异常

再次通过升级CardLib项目可以很好地说明如何使用自定义异常。在当前的CardLib中,如果尝试访问索引小于0或大于51的卡,则Deck.GetCard()方法会引发标准的.NET异常,但是您将对其进行修改以使用自定义异常。

1、接下来,定义异常。您可以使用在名为CardOutOfRangeException.cs的新类文件中定义的新类来执行此操作,可以使用项目➪添加类将其添加到CardLib项目中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public class CardOutOfRangeException : Exception
{
    private Cards deckContents;
    public Cards DeckContents
    {
        get { return deckContents; }
    }
    public CardOutOfRangeException(Cards sourceDeckContents)
        : base("There are only 52 cards in the deck.")
    {
        deckContents = sourceDeckContents;
    }
}

该类的构造函数需要Cards类的实例。它允许通过DeckContents属性访问此Cards对象,并将适当的错误消息提供给基本Exception构造函数,以便可以通过类的Message属性使用它。

2、接下来,添加代码以将此异常抛出到Deck.cs中,以替换旧的标准异常:

1
2
3
4
5
6
7
public Card GetCard(int cardNum)
{
    if (cardNum >= 0 && cardNum <= 51)
        return cards[cardNum];
    else
        throw new CardOutOfRangeException(cards.Clone() as Cards);                 
}

使用Deck对象的当前内容(形式为Cards对象)的深拷贝来初始化CardOutOfRangeException类中的DeckContents属性。这意味着您会在引发异常的那一刻看到内容,因此,对Deck内容的后续修改不会“丢失”此信息。

3、要对此进行测试,请使用以下客户端代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
static void Main(string[] args)
{
    Deck deck1 = new Deck();
    try
    {
        Card myCard = deck1.GetCard(60);
    }
    catch (CardOutOfRangeException e)
    {
        WriteLine(e.Message);
        WriteLine(e.DeckContents[0]);
    }
    ReadKey();
}

在这里,捕获代码已将异常Message属性写入屏幕。您还显示了通过DeckContents获得的Cards对象中的第一张卡片,只是为了证明您可以通过自定义异常对象访问Cards集合。

扩展和使用CardLib

现在,您已经了解了定义和使用事件的知识,您可以在CardLib中使用它们。当您使用GetCard获取Deck对象中的最后一个Card对象时,将生成您要添加到库中的事件,该事件称为LastCardDrawn。该事件使订阅者可以自动重新排列牌组,从而减少了客户端所需的处理。该事件将使用EventHandler委托类型,并将对Deck对象的引用作为其事件源传递,这样,无论处理程序在哪里,都可以访问Shuffle()方法。

1、将以下代码添加到Deck.cs来定义和引发事件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public event EventHandler LastCardDrawn;

public Card GetCard(int cardNum)
{
    if (cardNum >= 0 && cardNum <= 51)
    {
        if ((cardNum == 51) && (LastCardDrawn != null))
            LastCardDrawn(this, EventArgs.Empty);
        return cards[cardNum];
    }
    else
        throw new CardOutOfRangeException(cards.Clone() as Cards);                 
}

2、在花了时间开发CardLib库之后,不使用它会很可惜。在完成C#和.NET Framework中有关OOP的这一部分之前,是时候做些有趣的事情,并编写使用熟悉的纸牌类的纸牌游戏应用程序的基础知识。

首先,您将在Ch13CardClient\Player.cs的新文件中创建一个名为Player的新类。此类将包含两个自动属性:Name(string)和PlayHand(Cards)。这两个属性都有专用的set访问器,但尽管如此,PlayHand仍可对其内容进行写访问,从而使您可以修改玩家手中的牌。

您还可以通过将默认构造函数设为私有来隐藏默认构造函数,并提供一个公共的非默认构造函数,该构造函数接受Player实例的Name属性的初始值。

最后,您将提供一个名为HasWon()的布尔类型方法,如果玩家手中的所有卡牌都有相同花色(简单的获胜条件,但没关系),该方法将返回true。

这是Player.cs的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using CardLib;

namespace CardClient
{
    public class Player
    {
        public string Name { get; private set; }
        public Cards PlayHand { get; private set; }
        private Player() { }
        public Player(string name)
        {
            Name = name;
            PlayHand = new Cards();
        }
        public bool HasWon()
        {
            bool won = true;
            Suit match = PlayHand[0].suit;
            for (int i = 1; i < PlayHand.Count; i++)
            {
                won &= PlayHand[i].suit == match;
            }
            return won;
        }
    }
}

3、接下来,定义一个将处理纸牌游戏本身的类,称为Game。此类可在CardClient项目的文件Game.cs中找到。该类具有四个私有成员字段:

  • playDeck - Deck类型的变量,包含要使用的纸牌
  • currentCard - 一个int值,用作指向卡片组中要绘制的下一张卡的指针
  • players - 代表游戏玩家的玩家对象数组
  • discardedCards - 玩家弃用但未重新洗入卡组的Cards集合

Game类的默认构造函数初始化并随机洗存储在playDeck中的Deck,将currentCard指针变量设置为0(playDeck中的第一张纸牌),并将名为Reshuffle()的事件处理程序连接到playDeck.LastCardDrawn事件。处理程序只是简单地洗牌,初始化被丢弃的Cards集合,并将currentCard重置为0,以准备从新的牌堆中读取牌。

Game类还包含两个实用程序方法:SetPlayers()用于设置游戏的玩家(作为Player对象的数组)和DealHands()用于给玩家发牌(每人七张牌)。允许的玩家数量限制在2到7之间,以确保有足够的纸牌可以走动。

最后,有一个PlayGame()方法,其中包含游戏逻辑本身。查看Program.cs中的代码之后,我们很快就会回来分析此方法。Game.cs中的代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using CardLib;
using static System.Console;

namespace CardClient
{
    public class Game
    {
        private int currentCard;
        private Deck playDeck;
        private Player[] players;
        private Cards discardedCards;

        public Game()
        {
            currentCard = 0;
            playDeck = new Deck(true);
            playDeck.LastCardDrawn += Reshuffle;
            playDeck.Shuffle();
            discardedCards = new Cards();
        }

        private void Reshuffle(object source, EventArgs args)
        {
            WriteLine("Discarded cards reshuffled into deck.");
            ((Deck)source).Shuffle();
            discardedCards.Clear();
            currentCard = 0;
        }

        public void SetPlayers(Player[] newPlayers)
        {
            if (newPlayers.Length > 7)
                throw new ArgumentException(
                "A maximum of 7 players may play this game.");
            if (newPlayers.Length < 2)
                throw new ArgumentException(
                "A minimum of 2 players may play this game.");
            players = newPlayers;
        }

        private void DealHands()
        {
            for (int p = 0; p < players.Length; p++)
            {
                for (int c = 0; c < 7; c++)
                {
                    players[p].PlayHand.Add(playDeck.GetCard(currentCard++));
                }
            }
        }

        public int PlayGame()
        {
            // Code to follow.
            return 0;
        }
    }
}

4、Program.cs包含Main()方法,该方法初始化并运行游戏。此方法执行以下步骤:

  • 1.显示简介。
  • 2.提示用户输入2到7之间的人数。
  • 3.相应地设置了一个Player对象数组。
  • 4.提示每个玩家输入一个名称,该名称用于初始化数组中的一个Player对象。
  • 5.创建一个Game对象,并使用SetPlayers()方法分配玩家。
  • 6.使用PlayGame()方法开始游戏。
  • 7.PlayGame()的int返回值用于显示获胜消息(返回的值是获胜玩家在Player对象数组中的索引)。

此代码如下,并添加了注释以使内容更清楚:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using static System.Console;
using CardLib;

namespace CardClient
{
    class Program
    {
        static void Main(string[] args)
        {
            // 显示游戏介绍
            WriteLine("BenjaminCards: a new and exciting card game.");
            WriteLine("To win you must have 7 cards of the same suit in your hand.");
            WriteLine();

            // 提示玩家人数
            bool inputOK = false;
            int choice = -1;
            do
            {
                WriteLine("How many players (2–7)?");
                string input = ReadLine();
                try
                {
                    // 尝试将输入转换为有效数量的玩家
                    choice = Convert.ToInt32(input);
                    if ((choice >= 2) && (choice <= 7))
                        inputOK = true;
                }
                catch
                {
                    // 忽略失败的转换,仅继续提示
                }
            } while (inputOK == false);

            // 初始化Player对象数组
            Player[] players = new Player[choice];
            // 获取玩家名称
            for (int p = 0; p < players.Length; p++)
            {
                WriteLine($"Player {p + 1}, enter your name:");
                string playerName = ReadLine();
                players[p] = new Player(playerName);
            }

            // 开始游戏
            Game newGame = new Game();
            newGame.SetPlayers(players);
            int whoWon = newGame.PlayGame();
            // 显示获胜玩家
            WriteLine($"{players[whoWon].Name} has won the game!");
            ReadKey();
        }
    }
}

5、现在进入Game.cs的PlayGame()函数。由于篇幅所限,我们无法提供有关此方法的大量详细信息,但是对代码进行了注释以使其更易理解。代码不是很复杂;只是代码很多。

游戏规则:进行游戏时,每个玩家都可以查看自己的牌和桌上的一张翻开的牌。他们可以拿起这张卡片,也可以从牌组中抽出一张新卡片。抽出一张纸牌后,每位玩家必须丢弃一张纸牌,如果已将另一张纸牌拿起,则将其替换为桌上的另一张纸牌,或者将废弃的纸牌放在桌上的另一张纸牌上(还将废弃的纸牌添加到discardedCards集合中)。

在考虑此代码时,请记住如何操作Card对象。现在应该清楚将这些对象定义为引用类型而不是值类型(使用结构)的原因。给定的Card对象似乎可以同时存在于多个位置,因为Deck对象,Player对象的hand字段,discardedCards集合和playCard对象(当前在桌子上的卡)可以保留引用。这使跟踪卡片变得容易,尤其是用于从卡组中提取新卡片的代码中。仅当该卡不在任何玩家的手或废弃的Cards集合中时才被接受。

PlayGame()代码如下:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
public int PlayGame()
{
    // 仅在存在玩家的情况下玩
    if (players == null)
        return -1;
    // 发票
    DealHands();
    // 初始化游戏变量,包括放置在桌上的初始卡:playCard。
    bool GameWon = false;
    int currentPlayer;
    Card playCard = playDeck.GetCard(currentCard++);
    discardedCards.Add(playCard);
    // 主游戏循环一直持续到GameWon == true为止。
    do
    {
        // 在每个游戏回合中轮询玩家
        for (currentPlayer = 0; currentPlayer < players.Length;
                currentPlayer++)
        {
            // 显示当前玩家,玩家手牌和卡牌
            WriteLine($"{players[currentPlayer].Name}'s turn.");
            WriteLine("Current hand:");
            foreach (Card card in players[currentPlayer].PlayHand)
            {
                WriteLine(card);
            }
            WriteLine($"Card in play: {playCard}");
            // 提示玩家拿起桌上的卡或画一张新卡
            bool inputOK = false;
            do
            {
                WriteLine("Press T to take card in play or D to draw:");
                string input = ReadLine();
                if (input.ToLower() == "t")
                {
                    // 将桌上的卡添加到玩家手上
                    WriteLine($"Drawn: {playCard}");
                    // Remove from discarded cards if possible (if deck
                    // is reshuffled it won't be there any more)
                    if (discardedCards.Contains(playCard))
                    {
                        discardedCards.Remove(playCard);
                    }
                    players[currentPlayer].PlayHand.Add(playCard);
                    inputOK = true;
                }
                if (input.ToLower() == "d")
                {
                    // Add new card from deck to player hand.
                    Card newCard;
                    // Only add card if it isn't already in a player hand
                    // or in the discard pile
                    bool cardIsAvailable;
                    do
                    {
                        newCard = playDeck.GetCard(currentCard++);
                        // Check if card is in discard pile
                        cardIsAvailable = !discardedCards.Contains(newCard);
                        if (cardIsAvailable)
                        {
                            // Loop through all player hands to see if newCard 
                            // is already in a hand.
                            foreach (Player testPlayer in players)
                            {
                                if (testPlayer.PlayHand.Contains(newCard))
                                {
                                    cardIsAvailable = false;
                                    break;
                                }
                            }
                        }
                    } while (!cardIsAvailable);
                    // Add the card found to player hand.
                    WriteLine($"Drawn: {newCard}");
                    players[currentPlayer].PlayHand.Add(newCard);
                    inputOK = true;
                }
            } while (inputOK == false);
            // Display new hand with cards numbered.
            WriteLine("New hand:");
            for (int i = 0; i < players[currentPlayer].PlayHand.Count; i++)
            {
                WriteLine($"{i + 1}: " +
                            $"{ players[currentPlayer].PlayHand[i]}");
            }
            // Prompt player for a card to discard.
            inputOK = false;
            int choice = -1;
            do
            {
                WriteLine("Choose card to discard:");
                string input = ReadLine();
                try
                {
                    // Attempt to convert input into a valid card number.
                    choice = Convert.ToInt32(input);
                    if ((choice > 0) && (choice <= 8))
                        inputOK = true;
                }
                catch
                {
                    // Ignore failed conversions, just continue prompting.
                }
            } while (inputOK == false);
            // Place reference to removed card in playCard (place the card
            // on the table), then remove card from player hand and add
            // to discarded card pile.
            playCard = players[currentPlayer].PlayHand[choice - 1];
            players[currentPlayer].PlayHand.RemoveAt(choice - 1);
            discardedCards.Add(playCard);
            WriteLine($"Discarding: { playCard}");
            // Space out text for players
            WriteLine();
            // Check to see if player has won the game, and exit the player
            // loop if so.
            GameWon = players[currentPlayer].HasWon();
            if (GameWon == true)
                break;
        }
    } while (GameWon == false);
    // End game, noting the winning player.
    return currentPlayer;
}

6、运行程序:

第II部分 Windows编程

第14章 基本桌面编程

多年来,Visual Studio为Windows开发人员提供了创建用户界面的两种选择:Windows Forms是创建针对经典Windows的应用程序的基本工具,而Windows Presentation Foundations(WPF)提供了更广泛的选择。 应用程序类型,并尝试解决Windows窗体的许多问题。WPF在技术上与平台无关,这使我们能够以多种有趣的方式使用它,正如您将在第25章中看到的那样,其中一种是我们可以使用它来创建针对其他Windows平台的应用程序,例如WPF。Surface平板电脑或Xbox。在本章和下一章中,您将学习如何使用WPF创建经典的Windows应用程序。

大多数图形Windows应用程序开发的核心是窗口设计器。通过将控件从“工具箱”拖放到窗口中,然后在运行应用程序时将其放置在希望它们出现的位置,可以创建用户界面。使用WPF,这只是部分正确,因为用户界面实际上完全是用另一种称为“可扩展应用程序标记语言”(XAML,发音为zammel)的语言编写的。Visual Studio允许您同时进行这两种操作,并且随着您对WPF的使用越来越熟悉,您很可能会将拖放控件与编写原始XAML结合起来。

在本章中,您将与Visual Studio WPF设计器一起使用,为您在前几章中编写的纸牌游戏创建许多窗口。您将学习使用Visual Studio附带的许多控件中的某些控件,这些控件涵盖了广泛的功能。通过Visual Studio的设计功能,开发用户界面和处理用户交互非常简单且有趣!在本书的范围内不可能展示Visual Studio的所有控件,因此本章着眼于一些最常用的控件,从标签和文本框到菜单栏和布局面板。

XAML

XAML是一种使用XML语法并允许以声明的分层方式将控件添加到用户界面的语言。也就是说,您可以添加XML元素形式的控件,并使用XML属性指定控件属性。您还可以使控件包含其他控件,这对于布局和功能都是必不可少的。

注意: 第21章将详细介绍XML。如果您现在想快速了解XML基础,最好跳过本章的前几页。

XAML在设计时考虑了当今功能强大的图形卡,因此,它使您能够使用这些图形卡通过DirectX提供的所有高级功能。下面列出了其中一些功能:

  • 浮点坐标和矢量图形可提供可缩放,旋转和以其他方式变换的布局,而不会降低质量
  • 2D和3D功能可实现高级渲染
  • 高级字体处理和渲染
  • 固体,渐变和纹理填充,以及UI对象的可选透明度
  • 动画情节提要,可以在各种情况下使用,包括用户触发的事件,例如鼠标单击按钮上的事件
  • 可重复使用的资源 您可以用来动态设置控件的样式

关注点分离

维护多年来编写的Windows应用程序时存在的一个问题是,它们经常将生成用户界面的代码与根据用户的操作执行的代码混在一起。这使得多个开发人员和设计人员很难在同一个项目上工作。WPF有两种解决方法。首先,通过使用XAML而不是C#来描述GUI,GUI变得独立于平台,并且实际上您可以在不使用任何代码的情况下呈现XAML。其次,这意味着将C#代码放置在与放置GUI代码不同的文件中是很自然的。Visual Studio利用了一种称为代码隐藏文件的东西,它们是动态链接到XAML文件的C#文件。

由于GUI与代码分开,因此可以创建用于设计GUI的量身定制的应用程序,而这正是Microsoft所做的。设计工具Blend for Visual Studio是设计人员在为WPF创建GUI时使用的首选工具。该工具可以加载与Visual Studio相同的项目,但是在Visual Studio中,开发人员比设计人员更关注开发人员,在Blend中则相反。这意味着在具有设计师和开发人员的大型项目中,每个人都可以使用他们喜欢的工具一起在同一个项目上工作,而不必担心会无意中影响其他项目。

XAML实战

命名空间

上一个示例的Window元素是XAML文件的根元素。该元素通常包括许多名称空间声明。默认情况下,Visual Studio设计器包括您应注意的两个名称空间:http://schemas.microsoft.com/winfx/2006/xaml/presentation 和 http://schemas.microsoft.com/winfx/2006/xaml 。第一个是WPF的默认名称空间,它声明了许多将用于创建用户界面的控件。第二个声明XAML语言本身。不必在root标记上声明命名空间,但这样做可以确保可以在整个XAML文件中轻松访问其内容,因此几乎不需要移动声明。

注意: 名称空间看起来可能是URL,但这是在欺骗。实际上,它们就是所谓的统一资源标识符(URI)。 URI可以是任何字符串,只要它唯一地标识资源即可。Microsoft已选择以通常用于URL的形式指定URI,但是如果您在浏览器中键入地址,则不能保证会有要显示的页面。

在Visual Studio中创建新窗口时,演示文稿名称空间始终声明为默认名称,语言名称空间始终声明为xmlns:x。从“窗口”,“按钮”和“网格”标签可以看出,这可以确保不必在添加到窗口的控件之前添加前缀,但是指定的语言元素必须带有x前缀。

您经常看到的最后一个名称空间是系统名称空间:xmlns:sys="clr-name space:System;assembly=mscorlib”。使用此命名空间,您可以在XAML中使用 .NET Framework的内置类型。通过这样做,您编写的标记可以显式声明要创建的元素的类型。例如,可以在标记中声明一个数组,并声明该数组的成员是字符串:

1
2
3
4
5
6
7
8
9
<Window.Resources>
    <ResourceDictionary>
        <x:Array Type="sys:String" x:Key="localArray">
            <sys:String>"Benjamin Perkins"</sys:String>
            <sys:String>"Jacob Vibe Hammer"</sys:String>
            <sys:String>"Job D. Reid"</sys:String>
        </x:Array>
    </ResourceDictionary>
</Window.Resources>

代码隐藏文件

尽管XAML是声明用户界面的强大方法,但它不是编程语言。每当您要做的不仅仅是演示时,就需要C#。可以将C#代码直接嵌入到XAML中,但是从不建议将代码和标记混合使用,在本书中您将看不到它。您将看到很多东西是使用代码隐藏文件。这些文件是普通的C#文件,与XAML文件具有相同的名称,外加.cs扩展名。尽管可以随便叫他们一个名字,但最好还是遵循这个命名约定。当您在应用程序中创建新窗口时,Visual Studio会自动创建代码隐藏文件,因为它希望您将代码添加到窗口中。还将x:Class属性添加到XAML中的Window标记中:

1
<Window x:Class="Ch14Ex01.MainWindow"

这告诉编译器可以在类Ch14Ex01.MainWindow中找到该代码,而不是文件。因为您只能指定完全限定的类名,而不能指定在其中找到该类的程序集,所以无法将代码隐藏文件放在定义XAML的项目之外的某个位置。Visual Studio将代码隐藏文件与XAML文件放在同一目录中,因此您在Visual Studio中工作时不必担心这一点。

属性

如前所述,所有控件都具有许多用于操纵控件行为的属性。其中一些易于理解,例如高度和宽度,而其他则不太明显,例如RenderTransform。所有这些都可以使用XAML中的“属性”面板直接设置,也可以通过在“设计视图”上操纵控件来设置。下面的“试用”演示了在“设计视图”中设置控件的属性。

注意: 当您创建一个新项目时,Visual Studio将为您的类创建一个默认名称空间。当您向项目中添加新类或窗口时,将随后使用该名称空间。您可以通过在解决方案资源管理器中双击“属性”来更改名称空间。如果发现类获得的名称空间与示例中给出的名称空间不同,则将默认名称空间更改为本书中的名称空间可能会有所帮助。所做的更改只会影响新的类,而不会影响项目中已有的任何类。

依赖属性

用户对对话框采取的操作(例如从列表中选择某项)通常应导致其他控件更改和更新其显示或内容。在大多数情况下,常规 .NET属性是简单的getter和setter,它们无法告知其他控件它们已更改。输入依赖项属性。依赖项属性是一种以允许扩展功能的方式向WPF属性系统注册的属性。此扩展功能包括但不限于自动属性更改通知。具体来说,依赖项属性具有以下功能:

  • 您可以使用样式来更改依赖项属性的值。
  • 可以使用资源或数据绑定来设置依赖项属性的值。
  • 可以更改动画中的依赖项属性值。
  • 可以在XAML中分层设置依赖项属性,即,在父元素上设置的依赖项属性的值可用于为其子元素的相同依赖项属性设置默认值。
  • 可以使用定义良好的编码模式配置属性值更改的通知。
  • 您可以配置相关属性集,以便它们全部更新以响应其中一项的更改。这被称为强制。据说更改后的属性会强制其他属性的值。
  • 可以将元数据应用于依赖项属性以指定其他行为特征。例如,您可以指定如果给定属性更改,则可能有必要重新排列用户界面。

实际上,由于实现依赖项属性的方式,与普通属性相比,您可能不会注意到太多差异。但是,当您创建自己的控件时,您会很快发现使用普通的 .NET属性时,许多功能突然消失了。

第15章显示了如何实现新的依赖项属性。

附加属性

附加属性是一种属性,可用于定义该属性的类的实例的每个子对象。例如,正如您将在本章后面看到的那样,在前面的示例中使用的Grid控件允许您定义列和行,以对Grid的子控件进行排序。然后,每个子控件都可以使用附加的属性Column和Row来指定其在网格中的位置:

1
2
3
4
<Grid HorizontalAlignment="Left" Height="167" VerticalAlignment="Top" Width="290">
    <Button Content="Button" HorizontalAlignment="Left" Margin="10,10,0,0" VerticalAlignment="Top" Width="75" Grid.Column="0" Grid.Row="0" Height="22" />
    ...
</Grid>

在此,使用父元素的名称(Grid),句点(.)和附加属性的名称(Column和Row)来引用附加属性(Grid.Column和Grid.Row)。

在WPF中,附加属性可用于多种用途。当您查看如何在“控件布局”部分中放置控件时,很快就会看到很多附加属性。您将学习容器控件如何定义附加属性,这些属性使子控件可以定义例如停靠到容器的哪个边缘。

事件

在第13章中,您了解了什么是事件以及如何使用它们。本节介绍特定类型的事件,特别是WPF控件生成的事件,并介绍通常与用户操作关联的路由事件。例如,当用户单击一个按钮时,该按钮会生成一个事件,指示刚刚发生的事情。处理事件是程序员可以为该按钮提供某些功能的方式。

本书中使用的大多数控件都公开了您处理的许多事件。这包括诸如LostFocus和MouseEnter之类的事件。这是因为事件本身是从基本类(如Control或ContentControl)继承的。其他事件,例如DatePicker的CalendarOpened事件,则更加具体,只能在专门的控件上找到。表14-1中列出了一些最常用的事件。

处理事件

为事件添加处理程序有两种基本方法。一种方法是使用“属性”窗口中的“事件”列表,如图14-3所示,单击“闪电”按钮时将显示该列表。

要为特定事件添加处理程序,请键入事件的名称并按Enter,或双击“事件”列表中事件名称右侧的。这导致事件被添加到XAML标记中。处理事件的方法签名已添加到C#代码隐藏文件中。

您也可以直接在XAML中键入事件的名称,然后在其中添加处理程序的名称。如果您这样做,Visual Studio将在您键入时显示“新建事件处理程序”菜单。选择此项将为事件提供默认名称,并在代码隐藏文件中创建处理程序。如果您自己键入名称,则以后可以右键单击该事件,然后选择“转到定义”以在代码中生成事件处理程序。

路由事件

WPF使用称为路由事件的事件。标准的.NET事件由已显式订阅的代码处理,并且仅发送给那些订阅者。路由事件的不同之处在于,它们可以将事件发送到控件所参与的层次结构中的所有控件

路由事件可以在发生事件的控件的层次结构中上下移动。因此,如果右键单击按钮,则MouseRightButtonDown事件将首先发送给按钮本身,然后发送给控件的父级(在前面的示例中为Grid控件)。如果此操作无法解决,则事件最终会发送到窗口。另一方面,如果您不希望事件在层次结构中继续前进,则只需将RoutedEventArgs属性Handled设置为true,就不会再进行其他调用了。当事件像这样沿着控件层次结构传播时,称为冒泡事件

路由事件也可以沿另一个方向传播,即从根元素到执行操作的控件。这被称为隧道事件,并且按照惯例,所有此类事件都以“Preview”一词作为前缀,并且总是在其冒泡事件之前发生。一个示例是PreviewMouseRightButtonDown事件。

最后,路由事件的行为与正常的.NET事件完全相同,并且仅发送到在其上执行操作的控件。

路由命令

路由命令与事件的用途几乎相同,因为它们会导致一些代码执行。如果将事件直接绑定到XAML中的单个元素以及代码中的处理程序,则路由命令会更加复杂。

事件和命令之间的主要区别在于它们的使用。只要您有一段代码可以响应仅在应用程序中一个位置发生的用户操作,就应该使用事件。例如,当用户在窗口中单击“确定”以保存并关闭该事件时,可能会发生此类事件。当您具有将执行以响应在许多位置发生的动作的代码时,可以使用命令。例如,保存应用程序的内容时。通常,可以选择带有“保存”命令的菜单,以及用于相同目的的工具栏按钮。可以使用事件处理程序来执行此操作,但这意味着在许多位置实现相同的代码-命令可以使您只编写一次代码。

创建命令时,还必须实现可以回答以下问题的代码:“此代码现在是否可供用户使用?” 这意味着当命令与按钮关联时,该按钮可以询问命令是否可以执行并相应地设置其状态。

与事件相比,命令的实现要复杂得多,因此,直到第15章将它们与菜单项一起使用时,您才可以看到它们的使用。在下一个“Try It Out”练习中,您将事件处理程序添加到本章前面的示例中,以演示路由事件。

卡牌游戏客户端

既然您已经知道使用WPF和Visual Studio意味着什么的基础,现在该开始使用控件来创建有用的东西了。本章和第15章的其余部分专用于为您在前几章中开发的纸牌游戏编写游戏客户端。您将使用很多控件,甚至自己编写一个。

在本章中,您将编写游戏的支持对话框,其中包括“关于”,“选项”和“新游戏”窗口。

“关于”窗口

“关于”窗口,有时也称为“关于”框,用于显示有关应用程序开发人员和应用程序本身的信息。一些关于窗口非常复杂,例如在Microsoft Office应用程序和Visual Studio中找到的一个,并显示版本和许可信息。通常可以从“帮助”菜单访问“关于”窗口,该窗口通常是列表中的最后一项。

设计用户界面

“关于”窗口不是用户经常看到的东西。实际上,它通常位于“帮助”菜单上的原因是,仅当用户需要查找有关应用程序版本的信息或出现问题时与谁联系时,才经常使用它。但这也意味着用户有特定的访问目的,如果您在应用程序中包含此类窗口,则应将其视为重要。

无论何时设计应用程序,都应努力保持外观尽可能一致。这意味着您应该坚持使用几种选择的颜色,并在应用程序中的所有位置使用相同的控件样式。对于Karli卡,您将使用三种主要颜色-红色,黑色和白色。

如果查看图14-9,您将看到该窗口的左上角被Wrox Press徽标占据。您以前没有使用过图像,但是向应用程序中添加一些选择的图像可以使用户界面看起来更加专业。

Image控件

Image是一个非常简单的控件,可以发挥很大的作用。它允许您显示单个图像并根据需要调整该图像的大小。 该控件具有两个属性,如表14-3所示。

属性 描述
Source 使用此属性可以指定图像的位置。这可以是磁盘上或Web上的某个位置。正如您将在第15章中看到的那样,还可以创建一个静态资源并将其用作源。
Stretch 实际上,拥有恰好适合您的尺寸的图像是非常罕见的,有时,图像的尺寸必须随着调整应用程序窗口的大小而改变。您可以使用此属性来控制图像的行为。有四种可能性:None-图像不会调整大小。Fill-调整图像大小以填充整个空间。这可能会扭曲图像。Uniform-如果图像会改变纵横比,则图像会保持其纵横比,并且不会填充可用空间。UniformToFill-图像保持其纵横比并填充可用空间。如果保持该比例意味着某些图像对于可用空间太大,则将图像裁切为适合的大小。
Label控件

您已经看到了前面示例中使用的最简单的控件。它向用户显示简单的文本信息,并在某些情况下中继有关快捷键的信息。控件使用Content属性显示其文本。Label控件在一行上显示文本。如果为字母加上下划线“_”字符,则该字母将带有下划线,然后可以使用带前缀的字母和Alt直接访问控件。例如,_Name将快捷键Alt + N分配给标签后的任何控件。

TextBlock控件

与Label一样,此控件显示简单的文本,而没有任何复杂的格式。与Label不同,TextBlock控件能够显示多行文本。无法格式化文本的各个部分。

TextBlock将显示文本,即使该文本不适合授予控件的空间也是如此。在这种情况下,控件本身不提供任何滚动条,但是可以在需要时将其包装在方便的视图控件中:ScrollViewer。

Button控件

像Label控件一样,您已经看到了很多Button控件。此控件在任何地方都可以使用,并且可以在用户界面上轻松识别。您的用户将期望他们可以单击鼠标左键来执行操作-多多少少。更改此行为很可能会导致不良的界面设计和沮丧的​​用户。

默认情况下,该按钮以一行短文本或图像显示,该文本或图像描述了单击时发生的情况。

该按钮不包含任何显示图像或文本的属性,但是您可以使用Content属性显示简单文本或将Image控件嵌入内容中以显示图像。您可以在下载代码 Ch14Ex01\ImageButton.xaml 中找到以下代码:

1
2
3
4
5
6
<Button HorizontalAlignment="Left" VerticalAlignment="Top" Width="75" Margin="10" >
    <StackPanel Orientation="Horizontal">
        <Image Source=".\Images\Delete_black_32x32.png" Stretch="UniformToFill" Width="16" Height="16" />
        <TextBlock>Delete</TextBlock>
    </StackPanel>
</Button>
实践 - 创建"关于"窗口:KarliCards.Gui\AboutWindow.xaml

在启动About窗口之前,您需要一个项目。这只是您将在本章和下一章中创建的许多窗口之一,因此继续创建一个新的WPF App(.NET Framework)项目并将其命名为KarliCards.Gui。将解决方案命名为KarliCards。

1、在解决方案资源管理器中,右键单击KarliCards.Gui项目,然后选择“添加➪窗口”。将窗口命名为AboutWindow.xaml。

2、通过单击并拖动或设置以下属性来调整窗口大小:

1
2
Height="300" Width="434" MinWidth="434" MinHeight="300"
ResizeMode="CanResizeWithGrip"

3、选择Grid并通过单击网格边缘创建四行。不必太担心行的确切位置;而是像这样更改值:

1
2
3
4
5
6
<Grid.RowDefinitions>
    <RowDefinition Height="58"/>
    <RowDefinition Height="20"/>
    <RowDefinition />
    <RowDefinition Height="42"/>
</Grid.RowDefinitions>

4、将“Canvas”控件从“工具箱”拖到最上面的行。删除Visual Studio插入的所有属性,并添加以下内容:

1
Grid.Row="0" Background="#C40D42"

5、在新的Canvas控件中将Image控件添加到其上。像这样更改其属性:

1
2
Height="56" Canvas.Left="0" Canvas.Top="0" Stretch="UniformToFill"
Source=".\Images\Banner.png"

6、右键单击项目,然后选择添加➪新文件夹。创建一个名为Images的目录

7、在解决方案资源管理器中右键单击新目录,然后选择添加➪现有项。浏览到本章的图像。全部选中它们,然后单击添加。横幅现在显示在设计中。

8、选择“Canvas”控件并将“Label”控件拖到其上。更改其属性,如下所示:

1
2
Canvas.Right="10" Canvas.Top="25" Content="Karli Cards" Foreground="#FFF7EFEF"
FontFamily="Times New Roman"

9、选择“Grid”控件,然后将新的Canvas控件拖到其上。将其属性更改为:

1
Grid.Row="1" Background="Black"

10、选择新的Canvas控件并将一个Label拖到其上。更改其属性,如下所示:

1
2
3
Canvas.Left="5" Canvas.Top="0" FontWeight="Bold" FontFamily="Arial"
Foreground="White"
Content="Karli Cards (c) Copyright 2012 by Wrox Press and all readers"

11、再次选择Grid控件,然后将最后一个Canvas拖到最底行。更改其属性,如下所示:

1
Grid.Row="3"

12、选择新的Canvas控件并将一个Button拖到其上。将其属性更改为此:

1
Content="_OK" Canvas.Right="12" Canvas.Bottom="10" Width="75"

13、再次选择Grid,然后将StackPanel拖到最后一个中心行。将其属性更改为:

1
Grid.Row="2"

14、选择StackPanel并将两个Label控件和一个TextBlock依次拖入其中。

15、像这样更改最上面的Label控件:

1
2
3
Content="CardLib and Idea developed by Karli Watson" HorizontalAlignment="Left"
VerticalAlignment="Top" Padding="20,20,0,0" FontWeight="Bold"
Foreground="#FF8B6F6F"

16、像这样更改下一个Label控件:

1
2
3
Content="Graphical User Interface developed by Jacob Hammer"
HorizontalAlignment="Left" Padding="20, 0,0,0" VerticalAlignment="Top"
FontWeight="Bold" Foreground="#FF8B6F6F"

17、像这样更改TextBlock:

1
2
3
4
Text="Karli Cards developed with C# 7 for Wrox Press.
You can visit Wrox Press at http://www.wrox.com."
Margin="0, 10,0,0" Padding="20,0,0,0" TextWrapping="Wrap"
HorizontalAlignment="Left" VerticalAlignment="Top" Height="39"

18、双击按钮在代码隐藏文件生成事件处理程序,然后在事件处理程序中添加以下代码:

1
2
3
4
private void Button_Click(object sender, RoutedEventArgs e)
{
    this.Close();
}

19、在解决方案资源管理器中,双击App.xaml文件,然后将StartupUri属性从MainWindow.xaml更改为AboutWindow.xaml。

20、运行应用程序。

代码原理

首先,在Window上设置一些属性。通过设置MinWidth和MinHeight,可以防止用户将窗口的大小调整到模糊内容的程度。ResizeMode设置为CanResizeWithGrip它将在窗口的右下角显示一个小的握柄部分,向用户指示可以调整窗口的大小

接下来,向网格中添加四行。这样,您可以定义窗口的基本结构。通过将行1、2和4设置为固定高度,可以确保只有第三行可以更改高度;第三行这是保存内容的行。

然后添加第一个Canvas控件。这为您提供了方便的位置来设置第一行的背景颜色。通过确保画布没有特定大小,您可以强制画布填充网格中的第一行。

添加到画布的Image控件固定在画布的左边缘和上边缘。这样可以确保在调整窗口大小时,图像保持原样。您还为图像设置了固定的高度,但宽度保持打开状态。将Stretch属性设置为UniformToFill时,这允许Image控件将高度用作高宽比的参考。控件只是简单地更改其宽度以匹配由高度和纵横比指定的比例。

对于第一行的最后部分,您将添加一个Label控件并将其绑定到Canvas的右上边缘,以确保在调整窗口大小时,Label随右边缘移动。

然后,从第二行开始,该行由另一个添加了Label的Canvas控件填充。

底部的Canvas基本相同,但是这次您向其添加了一个按钮,并将该按钮绑定到画布的右下侧。这样可以确保在调整窗口大小时,按钮会粘在窗口的右下角。文本“OK”之前的下划线“_”为按钮创建Alt + O快捷键

最后,将StackPanel添加到第三行,并向其添加Labels和TextBlock控件。通过将第一个Label的Padding设置为20、20、0、0,您可以将控件的内容从上方的行向下推20个像素,然后从左侧边缘向下推20个像素。

下一个Label的填充设置为20,0,0,0,这将内容从左边缘推出,因为两个标Label之间的间隔很好,不需要任何额外的空间。

然后引入了TextBlock。属性TextWrapping设置为Wrap如果文本不能显示在一行上,则会导致文本换行。随着窗口大小的改变和行的变长,文本将自动根据需要插入到尽可能少的行中。边距和填充属性都在这里使用。设置了Margin属性,以便将整个控件从上面的Label下推10个像素,设置Padding属性以便将控件的内容从左边缘推20个像素。

事件处理程序中的代码关闭窗口。在这种情况下,这与关闭整个应用程序相同,因为在步骤19中,您将启动窗口更改为“关于”窗口,因此关闭它与关闭应用程序相同。

“选项”窗口

下一个要创建的窗口是“选项”窗口。该窗口将允许玩家设置一些可改变游戏玩法的参数。它还将允许您使用一些尚未使用的控件:CheckBox,RadioButton,ComboBox,TextBox和TabControl控件。

图14-11显示了选择了第一个选项卡的窗口。乍一看,该窗口看起来很像“关于”窗口,但是在该窗口上还有很多事情要做。

实践 - 设计“选项”窗口:KarliCards.Gui\OptionsWindow.xaml

当您看到“选项”窗口时,您可能会注意到的第一件事就是它看起来非常类似于“关于”窗口,这是事实。因此,可以重用至少先前示例中的某些代码。

1.在解决方案资源管理器中右键单击项目,然后选择添加➪窗口。将窗口命名为OptionsWindow.xaml。

2.删除默认情况下插入的Grid控件。

3.打开前面介绍的AboutWindow.xaml窗口,复制Grid控件及其所有内容,然后将其粘贴到新的OptionsWindow.xaml文件中。

4.像这样更改Window属性:

1
Title="Options" Height="345" Width="434" ResizeMode="NoResize"

5.删除StackPanel及其所有内容。

6.删除Grid.Row属性设置为3的Grid及其所有内容。

7.从Grid.Row属性设置为1的“Canvas”控件中删除“Label”控件。

8.像这样将Grid.Row属性设置为0的Canvas中的Label控件更改为:

1
<Label Canvas.Right="10" Canvas.Top="13" Content="Options" Foreground="#FFF7EFEF" FontFamily="Times New Roman" FontSize="24" FontWeight="Bold"/>

9.将StackPanel拖到底部一行并将其属性设置为此:

1
Grid.Row="3" Orientation="Horizontal" FlowDirection="RightToLeft"

10.像这样向StackPanel添加两个按钮:

1
2
<Button Content="_Cancel" Height="22" Width="75" Margin="10,0,0,0" Name="cancelButton" />
<Button Content="_OK" Height="22" Width="75" Margin="10,0,0,0" Name="okButton" />

11.将TabControl拖到第二行Canvas中,并按如下所示设置其属性:

1
Grid.RowSpan="2" Canvas.Left="10" Canvas.Top="2" Width="408" Height="208" Grid.Row="1"

12.将TabControl中的两个TabItem控件的每个的Header属性分别更改为Game和Computer Player。并分别在这两个TabItem中放入Grid

1
2
3
<Grid Background="#FFE5E5E5">

</Grid>

13.选择Game TabItem,然后将CheckBox控件拖到它上面。如下设置其属性:

1
2
Content="Play against computer" HorizontalAlignment="Left" Margin="11,33,0,0" 
VerticalAlignment="Top" Name="playAgainstComputerCheck"

14.先后将Label控件和一个ComboBox控件拖到TabItem中,并按如下所示设置其属性:

1
2
3
4
5
6
<Label Content="Number of players" HorizontalAlignment="Left" Margin="10,54,0,0" VerticalAlignment="Top" />
<ComboBox HorizontalAlignment="Left" Margin="196,58,0,0" VerticalAlignment="Top" Width="86" Name="numberOfPlayersComboBox" SelectedIndex="0" >
    <ComboBoxItem>2</ComboBoxItem>
    <ComboBoxItem>3</ComboBoxItem>
    <ComboBoxItem>4</ComboBoxItem>
</ComboBox>

15.选择第二个TabItem,标题为Computer Player。将一个Label和三个RadioButton拖到Grid上,并按如下所示设置其属性:

1
2
3
4
<Label Content="Skill Level" HorizontalAlignment="Left" Margin="10,10,0,0" VerticalAlignment="Top"/>
<RadioButton Content="Dumb" HorizontalAlignment="Left" Margin="37,41,0,0" VerticalAlignment="Top" IsChecked="True" Name="dumbAIRadioButton"/>
<RadioButton Content="Good" HorizontalAlignment="Left" Margin="37,62,0,0" VerticalAlignment="Top" Name="goodAIRadioButton"/>
<RadioButton Content="Cheats" HorizontalAlignment="Left" Margin="37,83,0,0" VerticalAlignment="Top" Name="cheatingAIRadioButton"/>

16.窗口的布局现已完成。打开App.xaml文件,然后将StartupUri更改为OptionsWindow.xaml。

17.运行应用程序。

代码原理

窗口的ResizeMode设置为NoResize。因此,由于用户无法再调整窗口的大小,因此您可以放置​​控件而不必考虑如果窗口更改大小会发生什么。

步骤9中的StackPanel具有一个新属性FlowDirection,该属性设置为RightToLeft。这将导致添加到该按钮的两个按钮紧贴对话框的右边缘,而不是默认的左边缘。有趣的是,这也改变了两个按钮的Margin属性的含义,从而导致Left和Right被交换

在不指定GroupName的情况下设置了第二个选项卡上的RadioButton,这将它们分组在一起。您在第一个上将IsChecked属性设置为true,这使其成为默认选择。

实践 - 在“选项”窗口中处理事件

此时该窗口看起来不错,即使更改设置也没有任何反应,用户甚至可以做一些事情。用户期望他们选择的选项由应用程序存储和使用。您可以通过将控件的值存储在窗口中来执行此操作,但这不是很灵活,并且会将应用程序的数据与GUI混合在一起,这不是一个好主意。相反,您应该创建一个类来保存用户所做的选择

在本实践中,将事件处理程序添加到“选项”窗口中,该事件处理程序在用户与控件交互时执行。

在此示例中,您将向项目添加一个新类,该类将包含用户所做的选择并处理用户更改选择时发生的事件。

1.将一个新类添加到项目中,并将其命名为GameOptions.cs。

2.输入以下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
using System;

namespace KarliCards.Gui
{
    [Serializable]
    public class GameOptions
    {
        public bool PlayAgainstComputer { get; set; }
        public int NumberOfPlayers { get; set; }
        public int MinutesBeforeLoss { get; set; }
        public ComputerSkillLevel ComputerSkill { get; set; }
    }

    [Serializable]
    public enum ComputerSkillLevel
    {
        Dumb,
        Good,
        Cheats
    }
}

3.返回到OptionsWindow.xaml.cs的代码隐藏文件,并添加一个私有字段来保存GameOptions实例:

1
private GameOptions gameOptions;

4.将此代码添加到构造函数中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
using System.IO;
using System.Windows;
using System.Xml.Serialization;

namespace KarliCards.Gui
{
    public partial class OptionsWindow : Window
    {
        private GameOptions gameOptions;
        public OptionsWindow()
        {
            if (gameOptions == null)
            {
                if (File.Exists("GameOptions.xml"))
                {
                    using (var stream = File.OpenRead("GameOptions.xml"))
                    {
                        var serializer = new XmlSerializer(typeof(GameOptions));
                        gameOptions = serializer.Deserialize(stream) as GameOptions;
                    }
                }
                else
                    gameOptions = new GameOptions();
            }

            InitializeComponent();
        }
    }
}

5.转到设计视图OptionsWindow.xaml,然后分别双击三个RadioButton生成时间处理程序,将Checked事件处理程序添加到代码隐藏文件中。像这样更改处理程序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
private void dumbAIRadioButton_Checked(object sender, RoutedEventArgs e)
{
    gameOptions.ComputerSkill = ComputerSkillLevel.Dumb;
}

private void goodAIRadioButton_Checked(object sender, RoutedEventArgs e)
{
    gameOptions.ComputerSkill = ComputerSkillLevel.Good;
}

private void cheatingAIRadioButton_Checked(object sender, RoutedEventArgs e)
{
    gameOptions.ComputerSkill = ComputerSkillLevel.Cheats;
}

6.双击OK和Cancel按钮生成时间处理程序,然后将此代码添加到处理程序方法中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
private void okButton_Click(object sender, RoutedEventArgs e)
{
    using (var stream = File.Open("GameOptions.xml", FileMode.Create))
    {
        var serializer = new XmlSerializer(typeof(GameOptions));
        serializer.Serialize(stream, gameOptions);
    }
    Close();
}

private void cancelButton_Click(object sender, RoutedEventArgs e)
{
    gameOptions = null;
    Close();
}

7.运行应用程序。

代码原理

当前,新类只是一些存储“选项”窗口中的值的属性。它被标记为Serializable以便可以将其保存到xml文件中

每当用户选择RadioButton时,都会引发该Checked事件。您处理此事件,以便设置GameOptions实例的ComputerSkillLevel属性的值。

数据绑定

数据绑定是将控件与数据声明性连接的一种方式。在“选项”窗口中,您处理了RadioButtons的Checked事件,以便在GameOptions类中设置ComputerSkillLevel属性的值。这很好用,您可以使用代码和事件处理来设置窗口中的所有值,但是通常最好将控件的属性直接绑定到数据。

绑定由四个部分组成:

  • 绑定目标,它指定要在其上使用绑定的对象
  • 目标属性,它指定要设置的属性
  • 绑定源,它指定绑定所使用的对象
  • 源属性,它指定哪个属性保存待绑定的数据

您并非总是明确地设置所有这些元素。特别是,绑定目标通常是由于您正在设置对控件属性的绑定而隐式指定的。

始终设置绑定源以便进行绑定,但是可以通过多种方式进行设置。在以下各节和第15章中,您将看到几种绑定源数据的方法。

DataContext

DataContext控件定义一个数据源,该数据源可用于在元素的所有子元素上进行数据绑定。您通常会拥有一个类的单个实例,该实例包含视图中使用的大多数数据。在这种情况下,您可以将窗口的DataContext设置为该对象的实例,这使您能够在视图中绑定该类的属性。在“动态绑定到外部对象”部分中对此进行了演示。

绑定到本地对象

您可以绑定到具有所需数据的任何.NET对象,只要编译器可以找到该对象即可。如果在与使用该对象的控件相同的上下文中找到相同的XAML块,则可以通过设置绑定的ElementName属性来指定绑定源。从“选项”窗口中查看此更改后的组合框:

1
<ComboBox HorizontalAlignment="Left" Margin="196,58,0,0" VerticalAlignment="Top" Width="86" Name="numberOfPlayersComboBox" SelectedIndex="0" IsEnabled="{Binding ElementName=playAgainstComputerCheck, Path=IsChecked}" >

注意IsEnabled属性。现在,在两个大括号内是很长的文本,而不是指定true或false。这种指定属性值的方法称为标记扩展语法,是指定属性的简写形式。也可以这样写的:

1
2
3
4
<ComboBox HorizontalAlignment="Left" Margin="196,58,0,0" VerticalAlignment="Top" Width="86" Name="numberOfPlayersComboBox" SelectedIndex="0" >
    <ComboBox.IsEnabled>
        <Binding ElementName="playAgainstComputerCheck" Path="IsChecked" />
    </ComboBox.IsEnabled>

这两个示例都将绑定源设置为名为playAgainstComputerCheck的CheckBox控件。在Path中将源属性指定为IsChecked属性。

绑定目标设置为IsEnabled属性。这两个示例都通过将绑定指定为属性的内容来完成此操作-它们只是使用不同的语法来完成此操作。最后,绑定目标是通过在ComboBox上进行绑定这一事实隐式指定的。

在此示例中的绑定导致根据CheckBox的IsChecked属性的值来设置或清除ComboBox的IsEnabled属性。结果是,没有任何代码,当用户更改CheckBox的值时,将启用和禁用ComboBox。

静态绑定到外部对象

通过指定在XAML中将类用作资源,可以动态创建对象实例。这是通过向XAML添加名称空间以允许该类被定位,然后将该类声明为XAML元素上的资源来完成的。

在接下来的实践中显示了如何在要数据绑定的对象的父元素上创建资源引用。

注意: 如果按照上一节中的说明更改了ComboBox,则应通过删除IsEnabled绑定来还原更改。

实践 - 创建静态数据绑定:KarliCards.Gui\NumberOfPlayers.cs

在此示例中,您将创建一个新类,以在“选项”窗口中保存ComboBox的数据,并将其绑定到控件。

1.将一个新类添加到项目中,并将其命名为NumberOfPlayers.cs。

2.添加以下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
using System.Collections.ObjectModel;

namespace KarliCards.Gui
{
    public class NumberOfPlayers : ObservableCollection<int>
    {
        public NumberOfPlayers() : base()
        {
            Add(2);
            Add(3);
            Add(4);
        }
    }
}

3.在OptionsWindow.xaml中,选择包含ComboBox的Canvas元素,并将此代码添加到其下方(TabControl声明上方):

1
2
3
<Canvas.Resources>
    <local:NumberOfPlayers x:Key="numberOfPlayersData" />
</Canvas.Resources>

4.选择ComboBox并从中删除三个ComboBoxItems,并添加ItemSource属性:

1
ItemsSource="{Binding Source={StaticResource numberOfPlayersData}}"
代码原理

此示例中发生了很多事情。NumberOfPlayers类从名为ObservableCollection的特殊集合派生。此基类是一个集合,已对其进行扩展以使其与WPF更好地配合使用。在NumberOfPlayers类的构造函数中,将可供ComboBox选择的玩家人数值添加到集合中。

接下来,在“Canvas”上创建一个新资源。您可以在ComboBox的任何父元素上创建此资源。在元素上指定资源后所有子元素都可以使用它

最后,将ItemsSource设置为绑定。ItemsSource属性专门用于允许您为Items控件上的项目集合指定绑定。在绑定中,您只需要指定绑定源即可。绑定目标,目标属性和源属性设置由ItemsSource属性处理。

动态绑定到外部对象

现在,您可以根据需要绑定到动态创建的对象,以提供一些数据。如果您已经具有要用于数据绑定的实例化对象该怎么办?在这种情况下,您需要对代码进行一些处理。

对于“选项”窗口,您不希望每次打开窗口时都将其清除,而是希望用户所做的选择能够保留并在应用程序的其余部分中使用。

在下面的实践中,将DataContext设置为GameOptions类的实例,该类允许您使用该类属性的动态绑定。

实践 - 创建动态绑定:KarliCards.Gui\GameOptions.cs

在此示例中,您将其余控件绑定到“选项”窗口中的GameOptions实例。

1.转到OptionsWindow.xaml.cs代码隐藏文件。

2.在构造函数的底部,但在InitializeComponent()上方,添加以下行:

1
DataContext = gameOptions;

3.转到GameOptions.cs,并进行如下更改:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
using System;
using System.ComponentModel;

namespace KarliCards.Gui
{
    [Serializable]
    public class GameOptions
    {
        private bool playAgainstComputer = true;
        private int numberOfPlayers = 2;
        private ComputerSkillLevel computerSkill = ComputerSkillLevel.Dumb;

        public int NumberOfPlayers
        {
            get { return numberOfPlayers; }
            set
            {
                numberOfPlayers = value;
                OnPropertyChanged(nameof(NumberOfPlayers));
            }
        }

        public bool PlayAgainstComputer
        {
            get { return playAgainstComputer; }
            set
            {
                playAgainstComputer = value;
                OnPropertyChanged(nameof(PlayAgainstComputer));
            }
        }

        public ComputerSkillLevel ComputerSkill
        {
            get { return computerSkill; }
            set
            {
                computerSkill = value;
                OnPropertyChanged(nameof(ComputerSkill));
            }
        }

        public event PropertyChangedEventHandler PropertyChanged;
        private void OnPropertyChanged(string propertyName)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }
    }

    [Serializable]
    public enum ComputerSkillLevel
    {
        Dumb,
        Good,
        Cheats
    }
}

4.返回OptionsWindow.xaml并选择CheckBox。添加IsChecked属性,如下所示:

1
 <CheckBox Content="Play against computer" HorizontalAlignment="Left" Margin="11,33,0,0" VerticalAlignment="Top" Name="playAgainstComputerCheck" IsChecked="{Binding Path=PlayeAgainstComputer}"/>

5.选择ComboBox并进行如下更改,删除SelectedIndex属性并更改ItemsSource和SelectedValue属性:

1
<ComboBox HorizontalAlignment="Left" Margin="196,58,0,0" VerticalAlignment="Top" Width="86" Name="numberOfPlayersComboBox" ItemsSource="{Binding Source={StaticResource numberOfPlayersData}}" SelectedValue="{Binding Path=NumberOfPlayers}"/>

6.运行该应用程序。

代码原理

通过将窗口的DataContext设置为GameOptions的实例,可以简单地通过指定要在绑定中使用的属性来绑定到该实例。这是在步骤4和5中完成的。请注意,ComboBox填充了静态资源中的项目,但是所选值是在GameOptions实例中设置的。

GameOptions类进行了很多更改。现在,它实现了INotifyPropertyChanged接口,这意味着该类现在能够通知WPF属性已更改。为了使此通知起作用,您必须调用接口定义的PropertyChanged事件的订阅者。为此,属性设置器必须主动调用它们,这是使用辅助方法OnPropertyChanged完成的。

调用OnPropertyChanged方法时,我们使用C# 6引入的新表达式:nameof。当我们使用表达式调用nameof(…)时,它将检索最终标识符的名称。在OnPropertyChanged方法的情况下,这特别有用,因为它采用要更改的属性的名称作为字符串。

OK按钮事件处理程序使用XmlSerializer将设置保存到磁盘。取消事件处理程序将游戏选项字段设置为null,以确保清除用户所做的选择。两个事件处理程序都关闭窗口。

使用ListBox控件开始游戏

现在,您只有一个窗口,没有创建游戏中的所有支持窗口。创建游戏板之前的最后一个窗口是玩家可以添加新玩家并选择将要参与新游戏的玩家的窗口。该窗口将使用ListBox显示玩家的名称。

ListBoxes和ComboBoxes通常可以用于相同的目的,但是ComboBox通常只允许您选择一个条目,而ListBoxes通常允许用户选择多个项目。另一个主要区别是,列表框将在始终展开的列表中显示其内容。这意味着它将在窗口上占用更多的空间,但允许用户立即查看可用的选项。

在下一个实践中,您创建一个当用户想要开始新游戏时显示的窗口。

实践 - 创建开始游戏窗口:KarliCards.Gui\StartGameWindow.xaml

新游戏开始时,向玩家显示该窗口。它将允许玩家输入他们的名字并从已知玩家列表中选择他们。

1.创建一个新窗口,并将其命名为StartGameWindow.xaml。

2.从窗口中删除Grid元素,然后从OptionsWindow.xaml窗口中复制主Grid及其内容。

3.从其Grid.Row属性设置为1的Canvas控件中删除所有内容。

4.将窗口标题更改为“Start New Game”并设置以下属性:

1
Height="345" Width="445" ResizeMode="NoResize"

5.将网格第0行中Label的内容更改为“New Game”

6.打开GameOptions.cs文件,并在类顶部添加以下字段:

1
2
private ObservableCollection<string> playerNames = new ObservableCollection<string>();
public List<string> SelectedPlayers { get; set; } = new List<string>();

7.先前的代码使用System.Collections.Generic和System.Collections.ObjectModel命名空间,因此GameOptions.cs头部包括以下内容:

1
2
using System.Collections.Generic;
using System.Collections.ObjectModel;

8.像这样向GameOptions.cs类添加一个属性和两个方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public ObservableCollection<string> PlayerNames
{
    get
    {
        return playerNames;
    }
    set
    {
        playerNames = value;
        OnPropertyChanged("PlayerNames");
    }
}

public void AddPlayer(string playerName)
{
    if (playerNames.Contains(playerName))
        return;
    playerNames.Add(playerName);
    OnPropertyChanged("PlayerNames");
}

9.返回到StartGameWindow.xaml窗口。

10.向Grid.Row为1的“Canvas”下方的Grid添加一个ListBox,两个Label,一个TextBox和一个Button,其中ListBox命名为playerNamesListBox,Button命名为addNewPlayerButton,TextBox命名为newPlayerTextBox,并设置好它们的Margin,Padding等属性。

13.像这样设置ListBox的ItemsSource:

1
ItemsSource="{Binding Path=PlayerNames}"

14.将ListBox的SelectionChanged事件处理程序添加到代码隐藏文件StartGameWindow.xaml.cs中,并添加以下代码:

1
2
3
4
5
6
7
8
9
private void playerNamesListBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
    if (gameOptions.PlayAgainstComputer)
        okButton.IsEnabled = 
            (playerNamesListBox.SelectedItems.Count == 1);
    else
        okButton.IsEnabled = 
            (playerNamesListBox.SelectedItems.Count == gameOptions.NumberOfPlayers);
}

15.将此字段添加到StartGameWindow类的顶部:

1
private GameOptions gameOptions;

16.将“OK”按钮的IsEnabled属性设置为false。

17.从代码隐藏的OptionsWindow.xaml.cs中复制构造函数(不要复制名称),并将下面的代码添加到InitializeComponent()之后的末尾(注意:您将需要使用System.IO和System.Xml.Serialization):

1
2
3
4
if (gameOptions.PlayAgainstComputer)
    playerNamesListBox.SelectionMode = SelectionMode.Single;
else
    playerNamesListBox.SelectionMode = SelectionMode.Extended;

18.选择Add按钮,然后添加Click事件处理程序。添加此代码:

1
2
3
4
5
6
private void addNewPlayerButton_Click(object sender, RoutedEventArgs e)
{
    if (!string.IsNullOrWhiteSpace(newPlayerTextBox.Text))
        gameOptions.AddPlayer(newPlayerTextBox.Text);
    newPlayerTextBox.Text = string.Empty;
}

19.将“OK”和“Cancel”按钮的事件处理程序从OptionsWindow.xaml.cs代码隐藏文件复制到该代码隐藏文件中。

20.将这些行添加到“OK”按钮处理程序的顶部:

1
2
3
4
foreach (string item in playerNamesListBox.SelectedItems)
{
    gameOptions.SelectedPlayers.Add(item);
}

21.转到App.xaml文件,然后将StartupUri更改为StartGameWindow.xaml。运行应用程序。

代码原理

首先,将代码添加到GameOptions类中,该类包含有关所有已知玩家以及在StartGame窗口中进行的当前选择的信息。

ListBox的ItemsSource属性与之前在ComboBox上看到的相同。但是,如果您能够将ComboBox的选定值直接绑定到一个值,则使用ListBox会更加复杂。如果您尝试绑定SelectedValues属性,则会发现它是只读的,因此不能用于数据绑定。此处使用的解决方法是使用“OK”按钮通过代码存储值。请注意,对IList<string>的强制转换在这里起作用,因为ListBox的内容目前是字符串,但是如果您决定更改默认行为并显示其他内容,则也必须更改此选择项。

只要发生更改选择的事件,就会引发ListBox的SelectionChanged事件。在这种情况下,您要处理此事件以检查所选项目的数量是否正确。如果要在计算机上玩游戏,那么只能有一个人类玩家。否则,必须选择正确数量的人类玩家。

注意:第15章讨论了样式,控件和Item模板,并说明了为什么不能总是知道控件内容是什么类型。

概要

关键概念 描述
XAML XAML是一种使用XML语法并允许以声明的分层方式将控件添加到用户界面的语言。
数据绑定 您可以使用数据绑定将控件的属性连接到其他控件的值。您还可以定义资源并使用在视图外部的类中定义的代码作为属性值的数据源或者控件内容。DataContexts可用于指定现有对象实例的绑定源,从而允许您绑定到在应用程序其他部分中创建的实例。
路由事件 路由事件是WPF中使用的特殊事件。它们有两种口味:冒泡和隧穿。冒泡事件首先在激活它们的控件上调用,然后冒泡通过视图树到达根元素。隧道事件以另一种方式移动,从根元素到用户激活的控件。通过将事件参数的Handled属性设置为true,可以停止冒泡和隧道传输。
INotifyPropertyChanged INotifyPropertyChanged接口由将在WPF视图中使用的类实现。调用该类的属性设置器时,它们将使用更改其值的属性的名称引发事件PropertyChanged。绑定到引发事件的属性的任何控件属性都将收到更改通知,并可以相应地进行更新。
ObservableCollections ObservableCollection是一个实现INotifyPropertyChanged接口的集合。 当您要向WPF视图提供列表中的属性或值以进行数据绑定时,可以使用此专用集合。
内容控件 内容控件可以在其内容中包含一个控件。这样的控件的一个示例是Button。该控件可以是Grid或StackPanel。它们使您可以创建复杂的自定义。
项目控件 项目控件可以在其内容中包含控件列表。这样的控件的一个示例是ListBox。列表中的每个控件都可以自定义。
布局控件 您学会了使用许多布局控件来帮助您创建视图:1. Canvas允许控件的显式定位。2. StackPanel堆栈控件水平或垂直。3. WrapPanel会根据面板的方向堆叠控件并将其包装到下一行或下一列。4. DockPanel允许您将控件停靠在控件的边缘或填充整个内容。5.Grid允许您定义行和列,并使用它们定位控件。
UI控件 UI控件通常在布局控件上引导其位置,从而在视图上显示自己。使用了以下控件:1.Label控件显示短文本。2. TextBlock控件显示可能需要多行显示的文本。3. TextBox控件允许用户提供文本输入。4.Button控件允许用户执行单个操作。5.Image控件用于显示图像。6.CheckBox可让用户回答“是/否”之类的问题,例如“玩电脑吗?” 7. RadioButton使用户可以从多个选项中精确选择一个。8.ComboBox显示项目的下拉列表,用户可以从中选择单个项目。该控件还可以显示一个TextBox,让用户输入新选项。9. ListBox控件显示项目列表。与ComboBox不同,该列表始终会扩展。该控件允许选择多个项目。10. TabControls允许您将页面上的控件分组。

第15章 高级桌面编程

到目前为止,您使用Windows Presentation Foundation(WPF)的方式与使用其他主要技术在Visual Studio中创建Windows应用程序的方式大致相同:Windows窗体。但这将改变。WPF可以设置任何控件的样式,并可以使用模板来更改现有控件,使其看上去像开箱即用。除此之外,您将通过键入XAML开始越来越多的工作。尽管起初看起来似乎很麻烦,但是通过设置属性来移动和微调显示的能力很快就会变成第二天性,并且您会发现XAML中有很多设计器无法完成的工作,例如 作为创建动画。

创建和样式控件

WPF的最佳功能之一是它可以为设计人员提供用户界面外观的完整控件。此功能的中心是在二维或三维中按需设置控件样式的功能。到目前为止,您一直在使用.NET附带的控件的基本样式,但实际的可能性是无限的。

本节介绍两种基本技术:

  • 样式-批量应用于控件的属性集
  • 模板-用于构建控件显示的控件

这里有些重叠,因为样式可以包含模板。

样式

WPF控件具有一个称为Style的属性(从FrameworkElement继承),可以将其设置为Style类的实例。Style类非常复杂,并且具有高级样式功能,但从本质上讲,它是一组Setter对象。每个Setter对象负责根据其Property属性(要设置的属性的名称)和其Value属性(将属性设置为的值)来设置属性的值。您可以将在Property中使用的名称完全限定为控件类型(例如Button.Foreground),也可以设置Style对象的TargetType属性(例如Button),以便它能够解析属性名称。

下面的代码显示如何使用Style对象设置Button控件的Foreground属性:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<Button>
    Click me!
    <Button.Style>
        <Style TargetType="Button">
            <Setter Property="Foreground">
                <Setter.Value>
                    <SolidColorBrush Color="Purple" />
                </Setter.Value>
            </Setter>
        </Style>
    </Button.Style>
</Button>

显然,在这种情况下,简单地以通常的方式设置按钮的Foreground属性会容易得多。当您将样式转换为资源时,样式将变得更加有用,因为可以重复使用资源。

模板

使用可自定义的模板构造控件。模板由用于构建控件显示的控件层次结构组成,其中可以包括控件的内容呈现器,例如显示内容的按钮。

控件的模板存储在其Template属性中,该属性是ControlTemplate类的实例。ControlTemplate类包含TargetType属性,可以将其设置为要为其定义模板的控件的类型。

通常,您使用样式来设置类的模板。这仅涉及通过以下方式提供用于Template属性的控件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<Button>
    Click me!
    <Button.Style>
        <Style TargetType="Button">
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="Button">
                    ...
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
    </Button.Style>
</Button>

某些控件可能需要多个模板。例如对于CheckBox控件使用一个模板的复选框(CheckBox.Template),并使用一个模板输出复选框旁边的文本(CheckBox.ContentTemplate)。

允许您更改控件内容的模板可以通过在要输出内容的位置包含ContentPresenter来进行更改。

在上一章中,您开发了三个具有相似外观的对话框窗口。对话框的常见元素之一是标题,您可以在其中更改每个对话框中Label的文本。您可以将标题定义为标签,然后在下一个实践中,开发一种新的Label样式,并使用它替换四个对话框中的标题。

实践 - 创建主窗口:KarliCards.Gui\GameClientWindow.xaml

1.右键单击项目,然后选择添加➪资源字典,以创建新的资源字典。将其命名为ControlResources.xaml。

2.为label创建一个新的控件模板,如下所示:

1
2
3
4
5
6
<ControlTemplate x:Key="HeaderTemplate" TargetType="{x:Type Label}">
    <Canvas Background="#C40D42" >
        <Image Height="56" Canvas.Left="0" Canvas.Top="0" Stretch="UniformToFill" Source=".\Images\Banner.png"/>
        <ContentPresenter Canvas.Right="10" Canvas.Top="25" Content="{TemplateBinding Content}" />
    </Canvas>
</ControlTemplate>

3.添加包括控件模板的样式:

1
2
3
4
5
6
7
<Style x:Key="HeaderLabelStyle" TargetType="Label">
    <Setter Property="Template" Value="{StaticResource HeaderTemplate}" />
    <Setter Property="FontFamily" Value="Times New Roman" />
    <Setter Property="FontSize" Value="24" />
    <Setter Property="FontWeight" Value="Bold" />
    <Setter Property="Foreground" Value="#FFF7EFEF" />
</Style>

4.创建一个名为GameClientWindow.xaml的新窗口。

5.将标题更改为“ Karli Cards Game Client”,然后删除“高度”和“宽度”属性。

6.将WindowState属性设置为Maximized。

7.在Window顶部,就在Grid之前,按如下所示导入资源字典:

1
2
3
4
5
6
7
<Window.Resources>
    <ResourceDictionary>
        <ResourceDictionary.MergedDictionaries>
            <ResourceDictionary Source="ControlResources.xaml"/>
        </ResourceDictionary.MergedDictionaries>
    </ResourceDictionary>
</Window.Resources>

8.将Grid的行定义插入到Grid控件中,如下所示:

1
2
3
4
5
6
7
8
<Grid>
    <Grid.RowDefinitions>
        <RowDefinition Height="58"/>
        <RowDefinition Height="20"/>
        <RowDefinition />
        <RowDefinition Height="42"/>
    </Grid.RowDefinitions>
</Grid>

9.插入新的Label控件而不是Canvas,如下所示:

1
<Label Grid.Row="0" Style="{StaticResource HeaderLabelStyle}">Karli Cards</Label>

代码原理

查看控件模板时,您会发现它与上一章中开发的窗口中的控件几乎完全相同;唯一的区别是ControlTemplate声明,并且ContentPresenter替换了Label控件。

ControlTemplate TargetType声明非常重要,因为它指定了此模板将针对的控件。这使您可以使用父控件到模板中控件的绑定属性。检查ContentPresenter:

1
<ContentPresenter Content="{TemplateBinding Content}" />

ContentPresenter控件使您可以指定控件类型的内容要去的地方。在GameClientWindow中,您指定Label的内容应为Karli Cards,这将导致显示文本。那通常是您要做的,但是控件展示器允许您指定任何内容,就像您期望content属性一样。

Style控件设置了我们要在标签上设置的属性,但是请注意,Template属性设置为对新HeaderTemplate的引用:

1
<Setter Property="Template" Value="{StaticResource HeaderTemplate}" />

触发器

WPF中的事件可以包括所有方式,包括按钮单击,应用程序启动和关闭事件,等等。WPF利用几种类型的触发器来提供诸如事件之类的功能,所有这些功能都从基本TriggerBase类继承。其中一个这样的触发器是EventTrigger类,它包含一组动作,每个动作都是从基本TriggerAction类派生的对象。激活触发器后,将执行这些操作。

您可以使用EventTrigger通过BeginStoryboard动作触发动画,使用ControllableStoryboardAction操纵情节提要,并使用SoundPlayerAction触发音效。

每个控件都有一个Triggers属性,可用于直接在该控件上定义触发器。您还可以在层次结构中进一步定义触发器,例如在Window对象上。在设置控件样式时,最常使用的触发器类型是“Trigger”(尽管您仍将使用EventTrigger来触发控件动画)。Trigger类用于设置属性以响应对其他属性的更改,并且在Style对象中使用时特别有用。

触发器对象的配置如下:

  • 若要定义Trigger对象监视的属性,请使用Trigger.Property属性。
  • 要定义Trigger对象何时激活,请设置Trigger.Value属性。
  • 若要定义触发器执行的操作,请将Trigger.Setters属性设置为Setter对象的集合。

这里提到的Setter对象与您在前面的“样式”部分中看到的对象完全相同。

以下代码显示了一个触发器,就像您在Style对象中使用它一样:

1
2
3
4
5
6
7
<Style TargetType="Button">
    <Style.Triggers>
        <Trigger Property="IsMouseOver" Value="true">
            <Setter Property="Foreground" Value="Yellow" />
        </Trigger>
    </Style.Triggers>
</Style>

当Button.IsMouseOver属性为true时,此代码将Button控件的Foreground属性更改为Yellow。 IsMouseOver是几个非常有用的属性之一,您可以将其用作查找有关控件和控件状态的信息的快捷方式。顾名思义,如果鼠标悬停在控件上,则为真。这使您可以编码鼠标悬停。这样的其他属性包括IsFocused,用于确定控件是否具有焦点。IsHitTestVisible,指示是否可以单击控件(也就是说,不会被进一步堆叠的控件所遮挡);和IsPressed,指示是否按下了按钮。其中的最后一个仅适用于从ButtonBase继承的按钮,而其他按钮在所有控件上均可用。

您还可以通过使用ControlTemplate.Triggers属性来实现很多事情,该属性使您能够为包含触发器的控件创建模板。这是默认的Button模板能够通过其模板响应鼠标悬停,单击和焦点更改的方式。这也是您必须修改才能自己实现此功能的内容。

动画

通过使用Storyboard创建动画。定义复杂动画的最佳方法是使用诸如Expression Blend之类的设计器。但是,您也可以通过直接编辑XAML代码和通过C#代码来定义它们。

注意: 详细的图形动画超出了本书的范围。本节中的信息将使您大致了解动画的用途。

WPF中的动画是使用称为Storyboard(故事板)的对象定义的。使用故事板,可以为属性的值设置动画(例如,按钮的背景色)。重要的是要认识到,您可以用这种方式制作几乎所有属性的动画,而不仅仅是影响控件显示方式的属性。

可以使用事件触发器的BeginStoryboard属性在资源字典中或控件内部单独定义一个故事板。在故事板中,您可以定义一个或多个动画或时间表。

在上一节中,使用了触发器来设置当鼠标滑过Button控件时Button控件的前景值Foreground。检查以下代码,该代码使用故事板代替:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<Button Content="Animation" HorizontalAlignment="Left" Margin="197,63,0,0" VerticalAlignment="Top" Width="75">
    <Button.Triggers>
        <EventTrigger RoutedEvent="Button.MouseEnter">
            <BeginStoryboard>
                <Storyboard>
                    <ColorAnimation To="Yellow" Storyboard.TargetProperty="(Button.Foreground).(SolidColorBrush.Color)" FillBehavior="HoldEnd" Duration="0:0:1" AutoReverse="False" />
                </Storyboard>
            </BeginStoryboard>
        </EventTrigger>
        <EventTrigger RoutedEvent="Button.MouseLeave">
            <BeginStoryboard>
                <Storyboard>
                    <ColorAnimation To="Black" Storyboard.TargetProperty="(Button.Foreground).(SolidColorBrush.Color)" FillBehavior="HoldEnd" Duration="0:0:1"/>
                </Storyboard>
            </BeginStoryboard>
        </EventTrigger>
    </Button.Triggers>
</Button>

该按钮包含两个触发器,一个用于MouseEnter,另一个用于MouseLeave。它们每个都包含一个ColorAnimation,它将文本的前景色分别更改为黄色和黑色。使用触发器直接设置前景属性和使用故事板之间的区别在细节上:使用故事板可以在1秒钟内获得流畅的过渡,但是当您直接设置该属性时,它会立即发生。两者都是有价值的工具,应在适当的时候使用它们-太多的动画可能会使您的用户烦恼,但一些放置适当的动画会使应用程序看起来更壮观。

WPF用户控件(用户自定义控件)

图形纸牌游戏的一项主要功能是……纸牌。显然,您不会在WPF附带的标准控件中找到“扑克牌”控件,因此您必须自己创建它。

WPF提供了一组在许多情况下有用的控件。但是,与所有.NET开发框架一样,它也使您能够扩展此功能。具体来说,您可以通过从WPF类层次结构中的类派生类来创建自己的控件。

您可以从中派生的最有用的控件之一是UserControl。此类为您提供了WPF控件可能需要的所有基本功能,并且使您的控件可以与现有WPF控件套件无缝衔接。使用WPF控件可能希望实现的所有内容(例如动画,样式和模板)都可以通过用户控件实现。

您可以使用右键项目➪添加用户控件菜单项将用户控件添加到项目中。这为您提供了一块空白的画布(实际上是一个空白的网格),可以从中使用。用户控件是使用XAML中的顶级UserControl元素定义的,而背后代码中的类则派生自System.Windows.Controls.UserControl类。

将用户控件添加到项目后,可以添加控件以布置视觉外观,并在代码后方配置控件。完成此操作后,您可以在整个应用程序中使用它,甚至可以在其他应用程序中重用它。

创建用户控件时,您需要了解的关键事项之一是如何实现依赖项属性。第14章简要讨论了这种属性,现在您距离编写自己的控件越来越近了,现在该看看它们了。

实现依赖项属性

您可以将依赖项属性添加到从System.Windows.DependencyObject继承的任何类中。此类在WPF中的许多类(包括所有控件和UserControl)的继承层次结构中。

若要对类实现依赖项属性,请将公共的静态成员添加到类型为System.Windows.DependencyProperty的类定义中。该成员的名称取决于您,但是最佳实践是遵循命名约定<PropertyName> Property:

1
public static DependencyProperty MyStringProperty;

将该属性定义为静态的方法似乎很奇怪,因为最终您可以为类的每个实例唯一定义一个属性。WPF属性框架会为您跟踪情况,因此您暂时不必担心这一点。

您添加的成员必须使用静态DependencyProperty.Register()方法进行配置:

1
2
public static DependencyProperty MyStringProperty =
    DependencyProperty.Register(...);

此方法采用三至五个参数,如表15-1所示(这些按顺序显示,前三个参数为强制性参数)。

参数 用处
string name 属性名称
Type propertyType 属性类型
Type ownerType 包含该属性的类的类型
PropertyMetadata typeMetadata 其他属性设置:属性的默认值和用于属性更改通知和强制的回调方法
ValidateValueCallback validateValueCallback 用于验证属性值的回调方法

注意: 还有其他方法可用于注册依赖项属性,例如RegisterAttached(),可用于实现附加属性。不会在本章中介绍这些方法,但是值得一读。

例如,您可以使用以下三个参数注册MyStringProperty依赖项属性:

1
2
3
4
5
6
7
public class MyClass : DependencyObject
{
    public static DependencyProperty MyStringProperty = DependencyProperty.Register(
        "MyString",
        typeof(string),
        typeof(MyClass));
}

您还可以包含.NET属性,该属性可用于直接访问依赖项属性(尽管这不是强制性的,正如您稍后将会看到的)。但是,由于依赖项属性被定义为静态成员,因此不能使用与普通属性相同的语法。要访问依赖项属性的值,您必须使用从DependencyObject继承的方法,如下所示:

1
2
3
4
5
public string MyString
{
    get { return (string)GetValue(MyStringProperty); }
    set { SetValue(MyStringProperty, value); }
}

在这里,GetValue()和SetValue()方法分别获取和设置当前实例的MyStringProperty依赖项属性的值。 这两个方法是公共的,因此客户端代码可以直接使用它们来操纵依赖项属性值。这就是为什么不要求添加.NET属性来访问依赖项属性的原因。

如果要为属性设置元数据,则必须使用从PropertyMetadata派生的对象(例如FrameworkPropertyMetadata),然后将此实例作为第四个参数传递给Register()。FrameworkPropertyMetadata构造函数有11个重载,并且它们采用表15-2中所示的一个或多个参数。

使用FrameworkPropertyMetadata的一个简单示例是使用它来设置属性的默认值:

1
2
3
4
5
public static DependencyProperty MyStringProperty = DependencyProperty.Register(
    "MyString",
    typeof(string),
    typeof(MyClass),
    new FrameworkPropertyMetadata("Default value"));

到目前为止,您已经了解了可以指定的三种回调方法,用于属性更改通知,属性强制和属性值验证。这些回调,与依赖项属性本身一样,必须全部作为公共的静态方法实现。每个回调都有一个特定的返回类型和参数列表,必须在回调方法上使用。

现在是时候回到正轨并继续使用Karli Cards的游戏客户端了。在下面的实践中,您将创建一个用户控件,该控件可以代表应用程序中的纸牌。

注意:您可以通过在编辑器中键入propdp并按Tab键来添加依赖项属性。

实践 - 用户控件:KarliCards.Gui\CardControl.xaml

从先前的实践返回到KarliCards.Gui项目。

1.此示例使用您在第13章中创建的CardLib项目,因此您必须将其添加到解决方案中。首先在解决方案资源管理器中右键单击解决方案名称,然后选择添加➪现有项目。浏览并从第13章代码示例中选择CardLib.csproj文件。

2.在KarliCards.Gui项目中,通过右键单击“引用”并在KarliCards.Gui项目中选择“添加引用”,添加对CardLib项目的引用。单击左侧树中的项目➪解决方案,然后选择CardLib。单击确定。

3.通过向项目添加新的类来添加新的值转换器。将其命名为RankNameConverter.cs并添加以下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
using System;
using System.Windows;
using System.Windows.Data;

namespace KarliCards.Gui
{
    [ValueConversion(typeof(CardLib.Rank), typeof(string))]
    public class RankNameConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, 
            object parameter, System.Globalization.CultureInfo culture)
        {
            int source = (int)value;
            if (source == 1 || source > 10)
            {
                switch (source)
                {
                    case 1:
                        return "Ace";
                    case 11:
                        return "Jack";
                    case 12:
                        return "Queen";
                    case 13:
                        return "King";
                    default:
                        return DependencyProperty.UnsetValue;
                }
            }
            else
                return source.ToString();
        }

        public object ConvertBack(object value, Type targetType,
            object parameter, System.Globalization.CultureInfo culture)
        {
            return DependencyProperty.UnsetValue;
        }
    }
}

4.添加命名为CardControl的新用户控件到KarliCards.Gui项目中。

5.设置UserControl的Height,Width和Name属性,如下所示:

1
Height="154" Width="100" Name="UserControl"

6.在Grid控件之前,添加将在控件的定义中使用的资源:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<UserControl.Resources>
    <local:RankNameConverter x:Key="rankConverter"/>
    <DataTemplate x:Key="SuitTemplate">
        <TextBlock Text="{Binding}"/>
    </DataTemplate>
    <Style TargetType="Image" x:Key="SuitImage">
        <Style.Triggers>
            <DataTrigger Binding="{Binding ElementName=UserControl, Path=Suit}" Value="Club">
                <Setter Property="Source" Value="Images\Clubs.png"/>
            </DataTrigger>
            <DataTrigger Binding="{Binding ElementName=UserControl, Path=Suit}" Value="Heart">
                <Setter Property="Source" Value="Images\Hearts.png"/>
            </DataTrigger>
            <DataTrigger Binding="{Binding ElementName=UserControl, Path=Suit}" Value="Diamond">
                <Setter Property="Source" Value="Images\Diamonds.png"/>
            </DataTrigger>
            <DataTrigger Binding="{Binding ElementName=UserControl, Path=Suit}" Value="Spade">
                <Setter Property="Source" Value="Images\Spades.png"/>
            </DataTrigger>
        </Style.Triggers>
    </Style>
</UserControl.Resources>

7.在Grid控件内,添加一个Rectangle控件,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<Rectangle RadiusX="12.5" RadiusY="12.5">
    <Rectangle.Fill>
        <LinearGradientBrush EndPoint="0.47, -0.167" StartPoint="0.86, 0.92">
            <GradientStop Color="#FFD1C78F" Offset="0"/>
            <GradientStop Color="#FFFFFFFF" Offset="1"/>
        </LinearGradientBrush>
    </Rectangle.Fill>
    <Rectangle.Effect>
        <DropShadowEffect Direction="145" BlurRadius="10" ShadowDepth="0"/>
    </Rectangle.Effect>
</Rectangle>

8.接下来,添加一个Path控件。完成此操作后,您将拥有一个如图15-1所示的控件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
 <Path Fill="#FFFFFFFF" Stretch="Fill" Stroke="{x:Null}" Margin="0,0,35,0" 
              Data="M12,0 
                    L47,0 
                    C18,25 17,81 23,98 
                    35,131 54,144 63,149 
                    L12,149 
                    C3,149 0,143 0,136 
                    L0,12 
                    C0,5 3,0 12,0 
                    z">
            <Path.OpacityMask>
                <LinearGradientBrush EndPoint="0.957,1.127" StartPoint="0,-0.06">
                    <GradientStop Color="#FF000000" Offset="0"/>
                    <GradientStop Color="#00FFFFFF" Offset="1"/>
                </LinearGradientBrush>
            </Path.OpacityMask>
        </Path>

9.现在,您有了一些看起来像纸牌背面的东西,但是我们希望该控件也显示正面,因此我们继续使用一些标签来显示纸牌的花色和等级:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<Label x:Name="SuitLabel" 
        Content="{Binding Path=Suit, ElementName=UserControl, Mode=Default}"
        ContentTemplate="{DynamicResource SuitTemplate}"
        HorizontalAlignment="Center" VerticalAlignment="Center"
        Margin="8,51,8,60"/>
<Label x:Name="RankLabel" Grid.ZIndex="1"
        Content="{Binding Path=Rank, ElementName=UserControl, Mode=Default, Converter={StaticResource ResourceKey=rankConverter}}"
        ContentTemplate="{DynamicResource SuitTemplate}"
        HorizontalAlignment="Left" VerticalAlignment="Top"
        Margin="8,8,0,0"/>
<Label x:Name="RankLabelInverted"
        Content="{Binding Path=Rank, ElementName=UserControl, Mode=Default, Converter={StaticResource ResourceKey=rankConverter}}"
        ContentTemplate="{DynamicResource SuitTemplate}"
        HorizontalAlignment="Right" VerticalAlignment="Bottom"
        Margin="0,0,8,8" RenderTransformOrigin="0.5,0.5">
    <Label.RenderTransform>
        <RotateTransform Angle="180"/>
    </Label.RenderTransform>
</Label>

10.最后,图像应显示卡片的花色,以使花色看起来很漂亮:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<Image Name="TopRightImage" Style="{StaticResource ResourceKey=SuitImage}" 
        Margin="12,12,8,0" HorizontalAlignment="Right" VerticalAlignment="Top"
        Width="18.5" Height="18.5" Stretch="UniformToFill"/>
<Image Name="BottomLeftImage" Style="{StaticResource ResourceKey=SuitImage}"
        Margin="12,0,8,12" HorizontalAlignment="Left" VerticalAlignment="Bottom"
        Width="18.5" Height="18.5" Stretch="UniformToFill" RenderTransformOrigin="0.5,0.5">
    <Image.RenderTransform>
        <RotateTransform Angle="180"/>
    </Image.RenderTransform>
</Image>

11.转到CardControl的代码隐藏,然后向类添加三个依赖项属性(您可以键入propdp并按两次Tab键以使Visual Studio为这些属性创建模板):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public static DependencyProperty SuitProperty = DependencyProperty.Register(
    "Suit",
    typeof(CardLib.Suit),
    typeof(CardControl),
    new PropertyMetadata(CardLib.Suit.Club, new PropertyChangedCallback(OnSuitChanged)));

public static DependencyProperty RankProperty = DependencyProperty.Register(
    "Rank",
    typeof(CardLib.Rank),
    typeof(CardControl),
    new PropertyMetadata(CardLib.Rank.Ace));

public static DependencyProperty IsFaceUpProperty = DependencyProperty.Register(
    "IsFaceUp",
    typeof(bool),
    typeof(CardControl),
    new PropertyMetadata(true, new PropertyChangedCallback(OnIsFaceUpChanged)));

public bool IsFaceUp
{
    get { return (bool)GetValue(IsFaceUpProperty); }
    set { SetValue(IsFaceUpProperty, value); }
}

public CardLib.Suit Suit
{
    get { return (CardLib.Suit)GetValue(SuitProperty); }
    set { SetValue(SuitProperty, value); }
}

public CardLib.Rank Rank
{
    get { return (CardLib.Rank)GetValue(RankProperty); }
    set { SetValue(RankProperty, value); }
}

12.将更改事件处理程序添加到该类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public static void OnSuitChanged(DependencyObject source, DependencyPropertyChangedEventArgs args)
{
    var control = source as CardControl;
    control.SetTextColor();
}

private static void OnIsFaceUpChanged(DependencyObject source, DependencyPropertyChangedEventArgs args)
{
    var control = source as CardControl;
    control.RankLabel.Visibility = control.SuitLabel.Visibility =
    control.RankLabelInverted.Visibility =
    control.TopRightImage.Visibility =
    control.BottomLeftImage.Visibility = 
    control.IsFaceUp ? Visibility.Visible : Visibility.Hidden;
}

13.在类中添加一个属性:

1
2
3
4
5
6
private CardLib.Card card;
public CardLib.Card Card
{
    get { return card; }
    private set { card = value; Suit = card.suit; Rank = card.rank; }
}

14.添加一个辅助方法来设置文本颜色并重载构造函数以获取卡片:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public CardControl(CardLib.Card card)
{
    InitializeComponent();
    Card = card;
}
private void SetTextColor()
{
    var color = (Suit == CardLib.Suit.Club || Suit == CardLib.Suit.Spade) ?
        new SolidColorBrush(Color.FromRgb(0, 0, 0)) :
        new SolidColorBrush(Color.FromRgb(255, 0, 0));
    RankLabel.Foreground = SuitLabel.Foreground = RankLabelInverted.Foreground = color;
}

15.转到GameClientWindow,然后在标签下方的窗口中添加一个新网格:

1
<Grid x:Name="contentGrid" Grid.Row="2" />

16.将主网格的背景颜色设置为绿色:

1
<Grid Background="Green">

17.转到代码隐藏文件,并按如下所示更改构造函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public GameClientWindow()
{
    InitializeComponent();

    var position = new Point(15, 15);
    for (var i = 0; i < 4; i++)
    {
        var suit = (CardLib.Suit)i;
        position.Y = 15;
        for (int rank = 1; rank < 14; rank++)
        {
            position.Y += 30;
            var card = new CardControl(new CardLib.Card((CardLib.Suit)suit, (CardLib.Rank)rank));

            card.VerticalAlignment = VerticalAlignment.Top;
            card.HorizontalAlignment = HorizontalAlignment.Left;
            card.Margin = new Thickness(position.X, position.Y, 0, 0);
            contentGrid.Children.Add(card);
        }

        position.X += 112;
    }

}

18.将App.xaml文件中的StartupUri更改为GameClientWindow.xaml,然后运行该应用程序。结果如图15-2所示。

代码原理

本示例创建一个具有两个相关属性的用户控件,并包括使用该控件的客户端代码。这个例子覆盖了很多基础,而开始查看代码的地方就是Card控件。

Card控件主要由您在本章前面看到的代码所熟悉的代码组成。第一部分定义了控件的许多资源。首先,它定义RankConverter类的实例,确保可以在XAML中使用它

1
<local:RankNameConverter x:Key="rankConverter"/>

接下来,定义一个DataTemplate。DataTemplate与ControlTemplate相似,因为它可用于更改控件的外观。但是,在ControlTemplate通常仅用于修改控件外观的情况下,DataTemplate用于呈现控件的基础数据,因此,例如,它可以用于显示控件DataContext的属性。

最后定义的资源是Image控件的样式。此样式定义了四个触发器,每个触发器都绑定到UserControl类的Suit属性。根据控件的值,触发器会将Image控件的Source属性设置为适当的图片:

1
2
3
<DataTrigger Binding="{Binding ElementName=UserControl, Path=Suit}" Value="Club">
    <Setter Property="Source" Value="Images\Clubs.png" />
</DataTrigger>

资源到位后,便开始绘制卡。绘制卡片的网格中的第一个控件是矩形,这可能有点令人惊讶,因为卡片具有圆角。这是通过设置控件的RadiusX和RadiusY属性来实现的:

1
<Rectangle RadiusX="12.5" RadiusY="12.5">

这两个属性实际上控制了矩形内部用于显示圆角的椭圆的x和y半径。

然后使用LiniarGradientBrush将矩形填充为颜色。StartPoint和EndPoint属性指定绘制渐变所沿的线。默认情况下,该行的范围是0,0(左上角)到1,1(右下角)。此处使用的渐变指定该行的起点靠近右下角,结束于x轴的中间,即控件顶部上方:

1
<LinearGradientBrush EndPoint="0.47,-0.167" StartPoint="0.86,0.92">

最后,将DropShadow效果添加到矩形,该矩形在控件周围绘制阴影。

接下来,将Path控件放置在Grid中。该控件使您可以使用直线和曲线绘制多边形。您可以使用C#代码对控件应描述的路径进行编程,也可以像在本示例中一样使用标记语法。由于为控件定义了路径,因此很难看到绘制的多边形。这是因为Stroke属性设置为null,因此出于解释目的,请尝试将其更改为Red。然后,您应该在卡上看到如图15-3所示的多边形。

Stretch属性也很重要。如果将此设置为“Fill”,并且调整了定义“Path”控件的大小,则多边形将使用父控件优雅地调整大小。最后,“Margin”会导致“Path”控件将其右边缘移动到父级右边缘的左侧35像素。

现在,看一下Data属性:

1
2
3
4
5
6
7
8
9
Data="M12,0
      L47,0
      C18,25 17,81 23,98
      35,131 54,144 63,149
      L12,149
      C3,149 0,143 0,136
      L0,12
      C0,5 3,0 12,0
      z"

您使用Data属性来设置路径。此属性采用非常特定格式的字符串。字符串中的某些数字以字母为前缀;其他不是,所以让我们深入研究。

路径以M12,0开头。坐标前的M指示路径这是路径的起点。它是大写字母M很重要,因为这意味着该坐标是绝对位置;如果是小写字母,则表示坐标是前一点的偏移量。如果不存在这样的点,则将0,0用作前一个点。这会将多边形的起点定位在左上角的左侧12个像素处。

下一条指令是L47,0。这将创建从当前点到指定点的直线。在这种情况下,它将绘制一条从12,0到47,0的垂直线。实现相同效果的另一种方法是编写h35或H47。大写或小写的H指示路径绘制垂直线。再次,大写字母表示绝对位置;小写表示与上一个点的距离。

第三条指令要长一点:

1
C18,25 17,81 23,98

C表示这是三次贝塞尔曲线。要绘制这样的曲线,您需要四个点:起点,用于指定起点和终点切线的两个点以及终点。起点由前一行的端点给出。集合中的前两个点是分别定义曲线起点和终点切线的控制点。第三点是终点。

下一组点不以字母开头。发生这种情况时,这些指令将被视为与上一条指令相同的类型,因此这是另一条曲线。

指令的其余部分只是更多的直线和曲线,直到您到达指定小写字母z的结尾为止。这意味着必须关闭多边形,以便从当前位置到起点画一条直线。在这种情况下,可以将其省略,因为最终曲线与多边形的起点会合,但为完整起见将其包括在内。

CardControl中的代码向客户端代码公开了三个依赖项属性,即Suit,Rank和IsFaceUp,并将这些属性绑定到控件布局中的可视元素。因此,将“Suit”设置为“Club”时,单词“Club”将显示在卡的中央,而Club图像将显示在卡的右上角和左下角。同样,Rank值显示在卡的其他两个角上。

稍后,您将了解这些属性的实现。现在就足以知道它们是您在第10章中开始的CardLib项目的枚举。

这三个标签显示卡牌的等级和花色。即使它们具有不同的属性,它们也有一些共同点。它们必须根据绑定属性的值以红色或黑色显示一些文本。在此示例中,使用Rank更改时引发的事件来设置颜色,但是您可以为此使用触发器:

1
2
3
4
5
<Label x:Name="SuitLabel" 
        Content="{Binding Path=Suit, ElementName=UserControl, Mode=Default}"
        ContentTemplate="{DynamicResource SuitTemplate}"
        HorizontalAlignment="Center" VerticalAlignment="Center"
        Margin="8,51,8,60"/>

绑定属性值时,还可以使用数据模板指定如何呈现绑定的内容。在此示例中,数据模板是SuitTemplate,称为动态资源(尽管在这种情况下,静态资源绑定也可以正常工作)。该模板在用户控制资源部分中定义如下:

1
2
3
4
5
<UserControl.Resources>
    <DataTemplate x:Key="SuitTemplate">
        <TextBlock Text="{Binding}"/>
    </DataTemplate>
</UserControl.Resources>

因此,Suit的字符串值用作TextBlock控件的Text属性。相同的DataTemplate定义被重用于两个Rank标签。Suit是一个枚举,枚举中值的名称自动转换为要在Text属性中显示的字符串。

两个Rank标签在绑定中包含一个值转换器:

1
2
3
4
5
<Label x:Name="RankLabel" Grid.ZIndex="1"
    Content="{Binding Path=Rank, ElementName=UserControl, Mode=Default, Converter={StaticResource ResourceKey=rankConverter}}"
    ContentTemplate="{DynamicResource SuitTemplate}"
    HorizontalAlignment="Left" VerticalAlignment="Top"
    Margin="8,8,0,0"/>

通过以下声明,转换器包含在UserControl资源中:

1
<local:RankNameConverter x:Key="rankConverter"/>

如果卸下值转换器,也不会破坏控件。相反,您将看到Ace,2、3、4,依此类推。您还将看到转换为字符串的枚举值的名称-Ace,Deuce,Three,Four等。尽管从技术上讲这是正确的,但看起来并不正确,因此您可以将值转换为数字和字符串的组合。

最后要注意的是RankLabel上的Grid.ZIndex=“1”属性分配。网格或画布上的控件的ZIndex属性确定保存控件的可视层。如果两个或多个控件占用相同的空间,则可以使用ZIndex强制其中一个转到最前面。通常,所有控件的ZIndex均为零,因此将单个控件设置为1意味着将其移到最前面。这是必需的,因为否则路径的模糊会模糊文本。

为了使此数据绑定起作用,您必须使用先前学习的技术定义三个依赖项属性。这些在用户控件的背后代码中定义如下(它们具有简单的.NET属性包装器,由于代码的简单性,此处无需显示):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public static DependencyProperty SuitProperty = DependencyProperty.Register(
    "Suit",
    typeof(CardLib.Suit),
    typeof(CardControl),
    new PropertyMetadata(CardLib.Suit.Club, new PropertyChangedCallback(OnSuitChanged)));

public static DependencyProperty RankProperty = DependencyProperty.Register(
    "Rank",
    typeof(CardLib.Rank),
    typeof(CardControl),
    new PropertyMetadata(CardLib.Rank.Ace));

public static DependencyProperty IsFaceUpProperty = DependencyProperty.Register(
    "IsFaceUp",
    typeof(bool),
    typeof(CardControl),
    new PropertyMetadata(true, new PropertyChangedCallback(OnIsFaceUpChanged)));

依赖项属性使用回调方法来验证其值,并且Suit和IsFaceUp属性还具有用于其值更改时的回调方法。

当Suit的值更改时,将调用OnSuitChanged()回调方法。此方法负责将文本颜色设置为红色(对于心形和菱形)或黑色(对于梅花和黑桃)。它是通过在方法调用的源上调用一个实用程序方法来实现的。这是必需的,因为回调方法是作为静态方法实现的,但是会将该事件作为参数引发的用户控件的实例传递给它,以便它可以与之交互。调用的方法是SetTextColor():

1
2
3
4
5
public static void OnSuitChanged(DependencyObject source, DependencyPropertyChangedEventArgs args)
{
    var control = source as CardControl;
    control.SetTextColor();
}

SetTextColor()方法是私有的,但显然仍然可以从OnSuitChanged()进行访问,因为尽管它们分别是实例方法和静态方法,但它们都是同一类的成员。SetTextColor()只是将控件的各种标签的Foreground属性设置为黑色或红色的纯色画笔,具体取决于Suit值。

IsFaceUp更改后,控件将显示或隐藏用于显示控件当前值的图像和标签。

包含GameClientWindow.xaml.cs代码隐藏文件中的代码以显示卡,并且只是临时的。它会为13个可能的值中的每个值生成一张卡,并在列中显示每个花色。

主窗口

应用程序的主窗口是玩游戏的地方,因此它只有几个控件。您将在本节中构建游戏,但是在开始之前,有几件事要做。您需要将菜单添加到游戏客户端窗口,并将已经构建的窗口绑定到菜单项。

菜单控件

大多数应用程序都包含某种菜单和工具栏。两者都是达到同一目的的一种方法:提供对应用程序内容的轻松导航。工具栏通常包含菜单提供的相同条目的子集,可以将其视为菜单项的快捷方式。

Visual Studio附带了Menu和Toolbar控件。此处的示例显示了Menu控件的用法,但使用Toolbar非常相似。

默认情况下,菜单项显示为水平条,您可以从中下拉菜单项列表。该控件是一个Items控件,因此可以更改内容中包含的默认项目。但是,通常将以某种形式使用MenuItems,如以下示例所示。每个MenuItem可以包含其他菜单项,并且可以通过相互嵌套MenuItems来构建复杂的菜单,但是您应尝试使菜单结构尽可能简单。

菜单的路由命令

路由命令在第14章中进行了简要讨论,但是现在您将第一次看到它们的实际作用。回想一下,这些命令类似于事件,因为它们在用户执行操作时执行代码,并且它们可以返回指示它们是否可以在任何给定时间执行的状态。

为什么要使用路由命令代替事件至少有三个原因:

  • 可以从应用程序中的多个位置触发将导致事件发生的操作。
  • 仅在某些条件下才可以访问UI元素,例如,如果没有要保存的内容,则将禁用“保存”按钮。
  • 您要从隐藏代码文件中断开处理事件的代码。

如果这些情况中的任何一个符合您的情况,请考虑使用路由命令。就游戏而言,菜单中的某些项目也应该可以从工具栏上获得。此外,“保存”操作仅在进行游戏时才可用,并且可能在菜单和工具栏上均可用。

注意: 重要的是在KarliCards GUI项目中设置正确的默认名称空间,以使示例正常工作。如果您发现编译器错误指出某个类或资源不是名称空间的成员,则可能使用的名称空间与本书中使用的名称空间不同。KarliCards解决方案使用两个根名称空间:用于CardLib项目的CardLib和用于KarliCards GUI项目的KarliCards.Gui。如果遇到问题,请尝试在整个项目中更改名称空间,以匹配本书中使用的名称空间。

实践 - 创建主窗口:KarliCards.Gui\GameClientWindow.xaml

在此示例中,您将继续在本章前面创建的GameClientWindow上工作。

1.打开ControlResource.xaml文件,并添加以下样式以供Menu控件使用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<Style x:Key="MainMenuStyle" TargetType="Menu">
    <Setter Property="Background" Value="Black" />
    <Setter Property="Foreground" Value="White" />
    <Setter Property="FontWeight" Value="Bold" />
</Style>
<Style x:Key="MainMenuItemStyle" TargetType="MenuItem">
    <Setter Property="Foreground" Value="White" />
</Style>
<Style x:Key="MainMenuSubMenuItemStyle" TargetType="MenuItem">
    <Setter Property="Foreground" Value="Black" />
    <Setter Property="Width" Value="200" />
    <Setter Property="Height" Value="22" />
</Style>
<Style x:Key="MenuItemSeperatorStyle" TargetType="Separator">
    <Setter Property="Foreground" Value="Black" />
</Style>

2.打开GameClientWindow,然后将“Menu”控件拖到Grid中。如下设置其属性:

1
2
<Menu Grid.Row="1" Margin="0" Style="{StaticResource MainMenuStyle}">
</Menu>

3.在设计视图中右键单击菜单,然后选择添加MenuItem。

4.将Header属性更改为_File, 请注意下划线。删除“高度”和“宽度”属性,并将“Style”设置为MainMenuStyle:

1
<MenuItem Header="_File" Style="{StaticResource MainMenuItemStyle}"/>

5.右键单击_File菜单项,然后选择“添加MenuItem”,在_File项内添加另一个MenuItem。设置Header和Style属性,如下所示:

1
2
3
<MenuItem Header="_File" Style="{StaticResource MainMenuItemStyle}">
    <MenuItem Header="_New Game" Style="{StaticResource MainMenuSubMenuItemStyle}"/>
</MenuItem>

6.将以下MenuItems添加到File菜单:

1
2
3
4
5
6
7
8
<MenuItem Header="_Open" Style="{StaticResource MainMenuSubMenuItemStyle}"/>
<MenuItem Header="_Save" Style="{StaticResource MainMenuSubMenuItemStyle}" Command="Save">
    <MenuItem.Icon>
        <Image Source="Images\base_floppydisk_32.png" Width="20" />
    </MenuItem.Icon>
</MenuItem>
<Separator Style="{StaticResource MenuItemSeperatorStyle}"/>
<MenuItem Header="_Close" Style="{StaticResource MainMenuSubMenuItemStyle}" Command="Close"/>

7.将这些MenuItem添加到与File MenuItem相同级别的菜单。

1
2
3
4
5
6
7
8
9
<MenuItem Header="_Game" Style="{StaticResource MainMenuItemStyle}">
    <MenuItem Header="_Undo" Style="{StaticResource MainMenuSubMenuItemStyle}"/>
</MenuItem>
<MenuItem Header="_Tools" Style="{StaticResource MainMenuItemStyle}">
    <MenuItem Header="_Options" Style="{StaticResource MainMenuSubMenuItemStyle}"/>
</MenuItem>
<MenuItem Header="Help" Style="{StaticResource MainMenuItemStyle}">
    <MenuItem Header="_About" Style="{StaticResource MainMenuSubMenuItemStyle}"/>
</MenuItem>

8.在主Grid控件上方,Window.Resources标签下方,将此命令绑定添加到窗口:

1
2
3
4
<Window.CommandBindings>
    <CommandBinding Command="ApplicationCommands.Close" CanExecute="CommandCanExecute" Executed="CommandExecuted" />
    <CommandBinding Command="ApplicationCommands.Save" CanExecute="CommandCanExecute" Executed="CommandExecuted" />
</Window.CommandBindings>

9.转到GameClientWindow.xaml.cs代码隐藏文件,并添加以下两种方法。您必须引用System.Windows.Input命名空间:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
private void CommandCanExecute(object sender, CanExecuteRoutedEventArgs e)
{
    if (e.Command == ApplicationCommands.Close)
        e.CanExecute = true;
    if (e.Command == ApplicationCommands.Save)
        e.CanExecute = false;
    e.Handled = true;
}

private void CommandExecuted(object sender, ExecutedRoutedEventArgs e)
{
    if (e.Command == ApplicationCommands.Close)
        this.Close();
    e.Handled = true;
}

10.更改GameClientWindow的构造函数,使其仅调用InitializeComponent():

1
2
3
4
public GameClientWindow()
{
    InitializeComponent();
}

13.运行该应用程序。

代码原理

运行应用程序时,您会注意到Game Client窗口最初显示为最大化,但是您可以根据需要调整窗口的大小。按住Alt键时,“File”菜单将获得焦点,并且“File中的F”带有下划线,表示您可以通过按F展开菜单。

展开菜单时,您会看到“Save”菜单已禁用,但是它在元素标题的右侧显示了一个磁盘图标以及文本“Ctrl-S”。 这意味着您可以通过按Ctrl-S(启用)来访问它。您可能想知道为什么显示此消息,因为您没有在任何地方设置任何快捷键。 是,您确实为菜单项设置了一个命令:

1
<MenuItem Header="_Save" Style="{StaticResource MainMenuSubMenuItemStyle}" Command="Save">

保存命令由WPF定义。“File”菜单中使用的“Save”和“Close”在ApplicationCommands类中定义,该类还定义了“Cut”,“Copy”,“Paste”和“Print”。当您为MenuItem指定Save命令时,快捷键Ctrl-S被分配给菜单项,因为它是大多数Windows应用程序中用于访问该功能的标准组合键。

在代码隐藏文件中,添加了两种方法来确定命令的状态和操作。在XAML中,您创建了两个使用如下方法的命令绑定:

1
2
3
4
<Window.CommandBindings>
    <CommandBinding Command="ApplicationCommands.Close" CanExecute="CommandCanExecute" Executed="CommandExecuted" />
    <CommandBinding Command="ApplicationCommands.Save" CanExecute="CommandCanExecute" Executed="CommandExecuted" />
</Window.CommandBindings>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
private void CommandCanExecute(object sender, CanExecuteRoutedEventArgs e)
{
    if (e.Command == ApplicationCommands.Close)
        e.CanExecute = true;
    if (e.Command == ApplicationCommands.Save)
        e.CanExecute = false;
    e.Handled = true;
}
private void CommandExecuted(object sender, ExecutedRoutedEventArgs e)
{
    if (e.Command == ApplicationCommands.Close)
        this.Close();
    e.Handled = true;
}

命令绑定的CanExecute部分指定了一种方法CommandCanExecute,该方法被用来确定该命令当前是否对用户可用。Executed部分指定了用户激活命令时应调用的方法CommandExecuted。请注意,命令的激活位置无关紧要。如果菜单项和按钮都包含“保存”命令,则绑定对两者均有效。

当前的CommandCanExecute实现对于目前功能来说太简单了,您需要在其中进行一些计算以确定应用程序是否准备好保存任何内容。由于您还没有要保存的游戏,因此只需为Save命令返回false即可。通过在CanExecuteRoutedEventArgs类上设置e.CanExecute属性来执行此操作。另一方面,可以很好地执行Close命令,因此您对该命令返回true。

CommandExecuted与CommandCanExecute执行相同的测试。如果确定要执行的命令是“关闭”命令,则它将关闭当前窗口。

各模块合并(MVVM)

在游戏开发的这一点上,您有两个独立的对话框,一个纸牌库和一个主窗口,该主窗口提供了要显示游戏的空白空间。这仍然需要进行大量工作,但是随着基础的建立,是时候开始游戏本身了。CardLib中的类描述了游戏的“域模型”,即可以分解为游戏的对象,需要对其进行一些重构以使其在Windows应用程序中更好地工作。接下来,您将编写游戏的“视图模型”,该类可以控制游戏的显示。然后,您将创建两个其他用户控件,这些用户控件使用Card用户控件以可视方式显示游戏。最后,您将在游戏客户端中将它们绑定在一起。

注意: “视图模型”一词来自WPF中的一种常用设计模式Model-View-ViewModel(MVVM)。此设计模式描述了如何从视图中分离代码并将其链接在一起。尽管本书并未尝试遵循这种模式,但本示例使用了MVVM的很多元素,例如将ViewModel与视图分开。在这种情况下,下面描述的域模型是MVVM名称的“模型”部分,而您正在创建的Windows是视图。

重构域模型(Model)

如前所述,领域模型是描述游戏对象的代码。目前,您在CardLib项目中拥有Card, Deck, Rank, Suit类,它们描述游戏的对象。

除了这些类之外,游戏还需要Player和ComputerPlayer类,因此您将添加它们。您还需要稍微修改Card和Deck类,以使其在Windows应用程序中更好地工作。

有很多工作要做,所以让我们开始吧。

注意: 此示例不使用前面各章中的CardClient类,因为控制台和Windows应用程序之间的差异是如此之大,以致几乎没有代码可以重复使用。

实践 - 完善域模型:KarliCards.Gui

本示例从上一个示例开始的地方继续。

1.游戏中的每个玩家在游戏中可以处于多个“状态”。您可以在PlayerState枚举中对此建模。转到CardLib项目,并为该项目创建一个新的PlayerState枚举类。您可以简单地创建一个新类并替换如下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// PlayerState.cs
using System;

namespace CardLib
{
    [Serializable]
    public enum PlayerState
    {
        Inactive,
        Active,
        MustDiscard,
        Winner,
        Loser
    }
}

2.接下来,当玩家身上发生某些事情时,您将引发一些事件。为此,您需要一些自定义事件参数,因此添加另一个名为PlayerEventArgs的类。现在,不必担心缺少Player类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// PlayerEventArgs.cs
using System;

namespace CardLib
{
    public class PlayerEventArgs : EventArgs
    {
        public Player Player { get; set; }
        public PlayerState State { get; set; }
    }
}

3.当卡发生交互时,您还需要引发事件,因此继续创建另一个名为CardEventArgs的类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// CardEventArgs.cs
using System;

namespace CardLib
{
    public class CardEventArgs : EventArgs
    {
        public Card Card { get; set; }
    }
}

4.枚举ComputerSkillLevel当前存在于GameOptions.cs类中(在KarliCards.Gui项目中)。从那里剪切它,并将其移动到CardLib项目中的ComputerSkillLevel.cs枚举类文件中。这会将其命名空间更改为CardLib,因此您必须将CardLib命名空间添加到GameOptions.cs和OptionsWindow.Xaml.cs文件中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// ComputerSkillLevel.cs
using System;

namespace CardLib
{
    [Serializable]
    public enum ComputerSkillLevel
    {
        Dumb,
        Good,
        Cheats
    }
}

5.Deck类应该改变。直接给出修改后的Deck类。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
// Deck.cs
using System;
using System.Collections.Generic;
using System.Linq;

namespace CardLib
{
    public delegate void LastCardDrawnHandler(Deck currentDeck);
    public class Deck : ICloneable
    {
        public event LastCardDrawnHandler LastCardDrawn;
        private Cards cards = new Cards();

        public Deck()
        {
            InsertAllCards();
        }

        protected Deck(Cards newCards)
        {
            cards = newCards;
        }

        public int CardsInDeck
        {
            get { return cards.Count; }
        }

        public Card GetCard(int cardNum)
        {
            if (cardNum >= 0 && cardNum <= 51)
            {
                if ((cardNum == 51) && (LastCardDrawn != null)) LastCardDrawn(this);
                return cards[cardNum];
            }
            else
                throw new CardOutOfRangeException(cards.Clone() as Cards);
        }

        public void Shuffle()
        {
            Cards newDeck = new Cards();
            bool[] assigned = new bool[cards.Count];
            Random sourceGen = new Random();
            for (int i = 0; i < cards.Count; i++)
            {
                int sourceCard = 0;
                bool foundCard = false;
                while (foundCard == false)
                {
                    sourceCard = sourceGen.Next(cards.Count);
                    if (assigned[sourceCard] == false)
                        foundCard = true;
                }
                assigned[sourceCard] = true;
                newDeck.Add(cards[sourceCard]);
            }
            newDeck.CopyTo(cards);
        }

        public void ReshuffleDiscarded(List<Card> cardsInPlay)
        {
            InsertAllCards(cardsInPlay);
            Shuffle();
        }

        public Card Draw()
        {
            if (cards.Count == 0) return null;
            var card = cards[0];
            cards.RemoveAt(0);
            return card;
        }

        public Card SelectCardOfSpecificSuit(Suit suit)
        {
            Card selectedCard = cards.FirstOrDefault(card => card?.suit == suit);
            if (selectedCard == null) return Draw();
            cards.Remove(selectedCard);
            return selectedCard;
        }

        public object Clone()
        {
            Deck newDeck = new Deck(cards.Clone() as Cards);
            return newDeck;
        }

        private void InsertAllCards()
        {
            for (int suitVal = 0; suitVal < 4; suitVal++)
            {
                for (int rankVal = 1; rankVal < 14; rankVal++)
                {
                    cards.Add(new Card((Suit)suitVal, (Rank)rankVal));
                }
            }
        }

        private void InsertAllCards(List<Card> except)
        {
            for (int suitVal = 0; suitVal < 4; suitVal++)
            {
                for (int rankVal = 1; rankVal < 14; rankVal++)
                {
                    var card = new Card((Suit)suitVal, (Rank)rankVal);
                    if (except?.Contains(card) ?? false) continue;
                    cards.Add(card);
                }
            }
        }
    }
}

6.游戏中将有两种类型的玩家:玩家,由真实的人控制;和由电脑控制的ComputerPlayer。像这样添加Player类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
// Player.cs
using System;
using System.ComponentModel;
using System.Linq;

namespace CardLib
{
    [Serializable]
    public class Player : INotifyPropertyChanged
    {
        public int Index { get; set; }
        protected Cards Hand { get; set; }
        private string name;
        private PlayerState state;

        public event EventHandler<CardEventArgs> OnCardDiscarded;
        public event EventHandler<PlayerEventArgs> OnPlayerHasWon;

        public PlayerState State
        {
            get { return state; }
            set
            {
                state = value;
                OnPropertyChanged(nameof(State));
            }
        }

        public virtual string PlayerName
        {
            get { return name; }
            set
            {
                name = value;
                OnPropertyChanged(nameof(PlayerName));
            }
        }

        public void AddCard(Card card)
        {
            Hand.Add(card);
            if (Hand.Count > 7)
                State = PlayerState.MustDiscard;
        }

        public void DrawCard(Deck deck)
        {
            AddCard(deck.Draw());
        }

        public void DiscardCard(Card card)
        {
            Hand.Remove(card);
            if (HasWon)
                OnPlayerHasWon?.Invoke(this, new PlayerEventArgs { Player = this, State = PlayerState.Winner });
            OnCardDiscarded?.Invoke(this, new CardEventArgs { Card = card });
        }

        public void DrawNewHand(Deck deck)
        {
            Hand = new Cards();
            for (int i = 0; i < 7; i++)
                Hand.Add(deck.Draw());
        }

        public bool HasWon => Hand.Count == 7 && Hand.Select(x => x.suit).Distinct().Count() == 1;

        public Cards GetCards() => Hand.Clone() as Cards;

        public event PropertyChangedEventHandler PropertyChanged;
        private void OnPropertyChanged(string propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

7.像这样添加ComputerPlayer类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// ComputerPlayer.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace CardLib
{
    [Serializable]
    public class ComputerPlayer : Player
    {
        private Random random = new Random();
        public ComputerSkillLevel Skill { get; set; }
        public override string PlayerName => $"Computer {Index}";

        public void PerformDraw(Deck deck, Card availableCard)
        {
            if (Skill == ComputerSkillLevel.Dumb)
                DrawCard(deck);
            else
                DrawBestCard(deck, availableCard, (Skill == ComputerSkillLevel.Cheats));
        }

        public void PerformDiscard(Deck deck)
        {
            if (Skill == ComputerSkillLevel.Dumb)
                DiscardCard(Hand[random.Next(Hand.Count)]);
            else
                DiscardWorstCard();
        }

        private void DrawBestCard(Deck deck, Card availableCard, bool cheat = false)
        {
            var bestSuit = CalculateBestSuit();
            if (availableCard.suit == bestSuit)
                AddCard(availableCard);
            else if (cheat == false)
                DrawCard(deck);
            else
                AddCard(deck.SelectCardOfSpecificSuit(bestSuit));
        }

        private void DiscardWorstCard()
        {
            DiscardCard(Hand.First(x => x.suit == CalculateWorstSuit()));
        }

        private Suit CalculateBestSuit() => OrderSuitsInHand().Last();

        private Suit CalculateWorstSuit() => OrderSuitsInHand().First();

        private List<Suit> OrderSuitsInHand()
        {
            var cardSuits = new Dictionary<Suit, int>();
            foreach (var card in Hand)
            {
                if (!cardSuits.ContainsKey(card.suit))
                    cardSuits.Add(card.suit, 0);
                cardSuits[card.suit]++;
            }
            return cardSuits.OrderBy(x => x.Value).Select(y => y.Key).ToList();
        }
    }
}

代码原理

那是很多代码和很多更改!但是,当您运行该应用程序时,似乎没有什么改变,但是为了使游戏正常工作,已经投入了大量的精力。

Deck类已经扩展了一些新方法。每当牌桌上的物品被清空时,废弃的卡牌应重新放回原处。为此,添加了InsertAllCards方法的重载,该方法获取了正在使用的卡的列表。属性CardsInDeck将用于告知卡组中还剩下多少张卡。如果玩家抽出卡组中的每张牌,您都希望将所有丢弃的卡重新混回到卡组中,因此,Shuffle方法现在允许卡组包含少于52张卡,而ReshuffleDiscarded方法允许您执行重新混组。Draw和SelectCardOfSpecificSuit均用于绘制卡片。从下载的代码添加到项目的Player和ComputerPlayer类中的大多数代码都非常容易理解。Player类可以抽签和弃牌。这与ComputerPlayer共享,但是计算机还具有无需用户干预即可决定要提取和丢弃哪些卡的功能。ComputerPlayer类也可以作弊:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public void PerformDraw(Deck deck, Card availableCard)
{
    if (Skill == ComputerSkillLevel.Dumb)
        DrawCard(deck);
    else
        DrawBestCard(deck, availableCard, (Skill == ComputerSkillLevel.Cheats));
}

public void PerformDiscard(Deck deck)
{
    if (Skill == ComputerSkillLevel.Dumb)
        DiscardCard(Hand[random.Next(Hand.Count)]);
    else
        DiscardWorstCard();
}

private void DrawBestCard(Deck deck, Card availableCard, bool cheat = false)
{
    var bestSuit = CalculateBestSuit();
    if (availableCard.suit == bestSuit)
        AddCard(availableCard);
    else if (cheat == false)
        DrawCard(deck);
    else
        AddCard(deck.SelectCardOfSpecificSuit(bestSuit));
}

作弊由一个牌桌辅助,允许计算机选择特定花色的卡。如果您允许计算机作弊,那么您将很难赢得任何游戏!

您还将注意到Player类实现INotifyPropertyChanged接口,属性PlayerName和State使用此接口将更改通知给任何观察者。特别是State属性在以后很重要,因为对该属性的更改将推动游戏的发展。

视图模型(ViewModel)

视图模型的目的是保留显示它的视图的状态。对于Karli Cards,这意味着您已经拥有一个视图模型类:GameOptions类。此类保存Options和StartGame窗口的状态。目前,您无法从选项中选择选定的玩家,因此您必须添加该能力。另外缺少“游戏客户端”窗口的视图模型,因此这是下一个任务。

游戏执行的视图模型必须反映游戏正在运行的所有部分。游戏的部分包括:

  • 当前玩家从中抽取牌的牌组
  • 当前玩家可以代替而不是获取牌的卡牌
  • 当前玩家
  • 许多参与玩家

视图模型还应该能够将更改通知给观察者,这意味着视图模型类需要实现INotifyPropertyChanged接口。

除了这些功能之外,视图模型还应该提供一种开始新游戏的方式。您将通过为菜单创建新的路由命令来执行此操作。该命令在视图模型中创建,但从视图中调用。

实践 - 视图模型:KarliCards.Gui

此示例继续执行KarliCards.Gui项目。

1.使用语句将以下名称空间添加到GameOptions类中:

1
2
3
using System.Windows.Input;
using System.IO;
using System.Xml.Serialization;

2.向GameOptions类添加新命令:

1
2
3
4
public static RoutedCommand OptionsCommand = new RoutedCommand(
    "Show Options",
    typeof(GameOptions), 
    new InputGestureCollection(new List<InputGesture> { new KeyGesture(Key.O, ModifierKeys.Control) }));

3.在GameOptions类中添加两个新方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void Save()
{
    using (var stream = File.Open("GameOptions.xml", FileMode.Create))
    {
        var serializer = new XmlSerializer(typeof(GameOptions));
        serializer.Serialize(stream, this);
    }
}

public static GameOptions Create()
{
    if (File.Exists("GameOptions.xml"))
    {
        using (var stream = File.OpenRead("GameOptions.xml"))
        {
            var serializer = new XmlSerializer(typeof(GameOptions));
            return serializer.Deserialize(stream) as GameOptions;
        }
    }
    else
        return new GameOptions();
}

4.如下更改OptionsWindow.xaml.cs代码隐藏文件的“OK”单击事件处理程序:

1
2
3
4
5
6
private void okButton_Click(object sender, RoutedEventArgs e)
{
    DialogResult = true;
    gameOptions.Save();
    Close();
}

5.从构造函数中删除除InitializeComponent调用之外的所有内容,并像这样钩住DataContextChanged事件:

1
2
3
4
5
6
public OptionsWindow()
{
    gameOptions = GameOptions.Create();
    DataContext = gameOptions;
    InitializeComponent();
}

6.打开StartGameWindow.xaml.cs代码隐藏文件,然后在构造函数中选择代码的最后四行。右键单击选定的代码,然后选择“快速操作和重构➪提取方法”,以提取一个名为ChangeListBoxOptions的新方法:

1
2
3
4
5
6
7
private void ChangeListBoxOptions()
{
    if (gameOptions.PlayAgainstComputer)
        playerNamesListBox.SelectionMode = SelectionMode.Single;
    else
        playerNamesListBox.SelectionMode = SelectionMode.Extended;
}

7.添加StartGame_DataContextChanged事件处理程序:

1
2
3
4
5
void StartGame_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{
    gameOptions = DataContext as GameOptions;
    ChangeListBoxOptions();
}

8.从构造函数中删除除InitializeComponent调用之外的所有内容,并像这样钩住DataContextChanged事件:

1
2
3
4
5
public StartGameWindow()
{
    InitializeComponent();
    DataContextChanged += StartGame_DataContextChanged;
}

9.像这样更改OK click事件处理程序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
private void okButton_Click(object sender, RoutedEventArgs e)
{
    var gameOptions = DataContext as GameOptions;
    gameOptions.SelectedPlayers = new List<string>();
    foreach (string item in playerNamesListBox.SelectedItems)
    {
        gameOptions.SelectedPlayers.Add(item);
    }
    this.DialogResult = true;
    this.Close();
}

10.创建一个新类,并将其命名为GameViewModel。首先实现INotifyPropertyChanged接口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
using CardLib;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Windows.Input;

namespace KarliCards.Gui
{
    public class GameViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;
        private void OnPropertyChanged(string propertyName) => 
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

11.添加一个属性以容纳当前玩家。此属性应使用OnPropertyChanged事件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
private Player currentPlayer;

public Player CurrentPlayer
{
    get { return currentPlayer; }
    set
    {
        currentPlayer = value;
        OnPropertyChanged(nameof(CurrentPlayer));
    }
}

12.与对CurrentPlayer属性所做的一样,在类中再添加四个属性及其相关字段:

1
2
3
4
public List<Player> Players { get; set; } 
public Card CurrentAvailableCard { get; set; }
public Deck GameDeck { get; set; }
public bool GameStarted { get; set; }

13.添加此私有字段以容纳游戏选项:

1
private GameOptions gameOptions;

14.添加两个路由命令:

1
2
3
4
5
6
7
public static RoutedCommand StartGameCommand = new RoutedCommand(
    "Start New Game",
    typeof(GameViewModel),
    new InputGestureCollection(new List<InputGesture> { new KeyGesture(Key.N, ModifierKeys.Control) }));
public static RoutedCommand ShowAboutCommand = new RoutedCommand(
    "Show About Dialog", 
    typeof(GameViewModel));

15.添加一个新的默认构造函数:

1
2
3
4
5
public GameViewModel()
{
    Players = new List<Player>();
    gameOptions = GameOptions.Create();
}

16.开始游戏时,必须初始化玩家和牌桌。将此代码添加到类中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public void StartNewGame()
{
    if (gameOptions.SelectedPlayers.Count < 1 
        || (gameOptions.SelectedPlayers.Count == 1 
        && !gameOptions.PlayAgainstComputer))
        return;
    CreateGameDeck();
    CreatePlayers();
    InitializeGame();
    GameStarted = true;
}

private void InitializeGame()
{
    AssignCurrentPlayer(0);
    CurrentAvailableCard = GameDeck.Draw();
}

private void AssignCurrentPlayer(int index)
{
    CurrentPlayer = Players[index];
    if (!Players.Any(x => x.State == PlayerState.Winner))
        Players.ForEach(x => x.State = (x == Players[index] ? PlayerState.Active : PlayerState.Inactive));
}

private void InitializePlayer(Player player)
{
    player.DrawNewHand(GameDeck);
    player.OnCardDiscarded += player_OnCardDiscarded;
    player.OnPlayerHasWon += player_OnPlayerHasWon;
    Players.Add(player);
}

private void CreateGameDeck()
{
    GameDeck = new Deck();
    GameDeck.Shuffle();
}

private void CreatePlayers()
{
    Players.Clear();
    for (var i = 0; i < gameOptions.NumberOfPlayers; i++)1
    {
        if (i < gameOptions.SelectedPlayers.Count)
            InitializePlayer(new Player
            {
                Index = i,
                PlayerName = gameOptions.SelectedPlayers[i]
            });
        else
            InitializePlayer(new ComputerPlayer
            {
                Index = i,
                Skill = gameOptions.ComputerSkill
            });
    }
}

17.最后,为玩家生成的事件添加两个事件处理程序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
void player_OnPlayerHasWon(object sender, PlayerEventArgs e)
{
    Players.ForEach(x => x.State = (x == e.Player ? PlayerState.Winner : PlayerState.Loser));
}

void player_OnCardDiscarded(object sender, CardEventArgs e)
{
    CurrentAvailableCard = e.Card;
    var nextIndex = CurrentPlayer.Index + 1 >= gameOptions.NumberOfPlayers ? 0 : CurrentPlayer.Index + 1;
    if (GameDeck.CardsInDeck == 0)
    {
        var cardsInPlay = new List<Card>();
        foreach (var player in Players)
            cardsInPlay.AddRange(player.GetCards());
        cardsInPlay.Add(CurrentAvailableCard);
        GameDeck.ReshuffleDiscarded(cardsInPlay);
    }
    AssignCurrentPlayer(nextIndex);
}

18.转到GameClientWindow.xaml。在Window声明下面,添加一个DataContext声明:

1
2
3
<Window.DataContext >
    <local:GameViewModel />
</Window.DataContext>

20.将三个命令绑定添加到CommandBindings声明中:

1
2
3
<CommandBinding Command="local:GameViewModel.StartGameCommand" CanExecute="CommandCanExecute" Executed="CommandExecuted" />
<CommandBinding Command="local:GameViewModel.ShowAboutCommand" CanExecute="CommandCanExecute" Executed="CommandExecuted" />
<CommandBinding Command="local:GameOptions.OptionsCommand" CanExecute="CommandCanExecute" Executed="CommandExecuted" />

21.将Command属性添加到“New Game”菜单项中,如下所示:

1
<MenuItem Header="_New Game" Style="{StaticResource MainMenuSubMenuItemStyle}" Command="local:GameViewModel.StartGameCommand"/>

22.将Command属性添加到“Options”菜单项:

1
Command="local:GameOptions.OptionsCommand"

23.将Command属性添加到“关于”菜单项中,如下所示:

1
Command="local:GameViewModel.ShowAboutCommand"

24.转到代码隐藏文件GameClientWindow.xaml.cs,然后更改CommandCanExecute和CommandExecuted方法,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
private void CommandCanExecute(object sender, CanExecuteRoutedEventArgs e)
{
    if (e.Command == ApplicationCommands.Close)
        e.CanExecute = true;
    if (e.Command == ApplicationCommands.Save)
        e.CanExecute = false;
    if (e.Command == GameViewModel.StartGameCommand)
        e.CanExecute = true;
    if (e.Command == GameOptions.OptionsCommand)
        e.CanExecute = true;
    if (e.Command == GameViewModel.ShowAboutCommand)
        e.CanExecute = true;
    e.Handled = true;
}

private void CommandExecuted(object sender, ExecutedRoutedEventArgs e)
{
    if (e.Command == ApplicationCommands.Close)
        this.Close();
    if (e.Command == GameViewModel.StartGameCommand)
    {
        var model = new GameViewModel();
        var startGameDialog = new StartGameWindow();
        var options = GameOptions.Create();
        startGameDialog.DataContext = options;
        var result = startGameDialog.ShowDialog();
        if (result.HasValue && result.Value == true)
        {
            options.Save();
            model.StartNewGame();
            DataContext = model;
        }
    }
    if (e.Command == GameOptions.OptionsCommand)
    {
        var dialog = new OptionsWindow();
        var result = dialog.ShowDialog();
        if (result.HasValue && result.Value == true)
            DataContext = new GameViewModel(); // Clear current game
    }
    if (e.Command == GameViewModel.ShowAboutCommand)
    {
        var dialog = new AboutWindow();
        dialog.ShowDialog();
    }
    e.Handled = true;
}

代码原理

再一次,您完成了很多工作,而在运行应用程序时显示的改变却很少。“选项”和“新游戏”菜单项已获得快捷键,现在可以使用Ctrl-O和Ctrl-N进行访问。下拉菜单时显示。这是因为您为菜单创建了两个新命令。您分别在GameOptions.cs和GameViewModel.cs中进行了此操作:

1
2
3
4
5
// GameOptions.cs
public static RoutedCommand OptionsCommand = new RoutedCommand(
    "Show Options",
    typeof(GameOptions), 
    new InputGestureCollection(new List<InputGesture> { new KeyGesture(Key.O, ModifierKeys.Control) }));
1
2
3
4
5
// GameViewModel.cs
public static RoutedCommand StartGameCommand = new RoutedCommand(
    "Start New Game",
    typeof(GameViewModel),
    new InputGestureCollection(new List<InputGesture> { new KeyGesture(Key.N, ModifierKeys.Control) }));

当您将InputGestures列表分配给命令时,快捷方式将自动与菜单相关联。

在游戏客户端的代码背后,您还添加了代码以将两个窗口显示为对话框。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
if (e.Command == GameViewModel.StartGameCommand)
{
    var model = new GameViewModel();
    var startGameDialog = new StartGameWindow();
    var options = GameOptions.Create();
    startGameDialog.DataContext = options;
    var result = startGameDialog.ShowDialog();
    if (result.HasValue && result.Value == true)
    {
        options.Save();
        model.StartNewGame();
        DataContext = model;
    }
}

通过将窗口显示为对话框,您可以返回一个值,该值指示是否应使用对话框的结果。您不能直接从窗口返回值;而是将窗口的DialogResult属性设置为true或false来指示成功或失败:

1
2
3
4
5
private void okButton_Click(object sender, RoutedEventArgs e)
{
    this.DialogResult = true;
    this.Close();
}

在第14章中,您被告知,如果要将DataContext设置为现有对象实例,则必须从代码中进行设置。在先前的代码中会发生这种情况,但是GameClientWindow.xaml中的XAML还会在应用程序启动时实例化一个新实例:

1
2
3
<Window.DataContext >
    <local:GameViewModel />
</Window.DataContext>

这个实例可确保该视图有一个DataContext,但在StartGame命令中将其交换为新实例之前并没有太多使用。

GameViewModel包含许多代码,但其中大部分只是玩家和Deck实例的属性和实例化。

游戏开始后,随着计算机或玩家做出选择,玩家和GameViewModel的状态将推动游戏前进。PlayerHasWon事件在GameViewModel中处理,并确保其他玩家的状态更改为Loser。

1
2
3
4
void player_OnPlayerHasWon(object sender, PlayerEventArgs e)
{
    Players.ForEach(x => x.State = (x == e.Player ? PlayerState.Winner : PlayerState.Loser));
}

您为玩家创建的另一个事件也在此处处理:CardDiscarded用于指示玩家已完成回合。这导致将CurrentPlayer设置为下一个可用玩家:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
void player_OnCardDiscarded(object sender, CardEventArgs e)
{
    CurrentAvailableCard = e.Card;
    var nextIndex = 
        CurrentPlayer.Index + 1 >= gameOptions.NumberOfPlayers ? 0 : CurrentPlayer.Index + 1;
    if (GameDeck.CardsInDeck == 0)
    {
        var cardsInPlay = new List<Card>();
        foreach (var player in Players)
            cardsInPlay.AddRange(player.GetCards());
        cardsInPlay.Add(CurrentAvailableCard);
        GameDeck.ReshuffleDiscarded(cardsInPlay);
    }
    AssignCurrentPlayer(nextIndex);
}

该事件处理程序还会检查牌堆中是否还有其他卡。如果没有更多的纸牌,事件处理程序将收集游戏中当前使用的纸牌列表,并使套牌生成一个新的,经过改组的套牌,其中仅包含已废弃的牌。

从GameClient.xaml.cs代码隐藏文件中的CommandExecuted方法调用StartGame方法。此方法使用三种方法来创建新的牌堆,创建并向玩家分发卡,最后设置CurrentPlayer来启动游戏。

完成纸牌游戏开发

您现在拥有了一个无法玩的完整游戏,因为游戏客户端中没有任何内容。为了运行游戏,您需要两个附加的用户控件,这些控件将使用DockPanel放置在游戏客户端上。

这两个用户控件分别称为CardsInHand(显示玩家的手)和GameDecks(显示主牌堆和可获得的卡)。

实践 - 完成游戏:KarliCards.Gui

同样,此示例继续您一直在进行的KarliCards.Gui项目。

1.右键单击项目,然后选择添加➪用户控件,在KarliCards.Gui项目中创建一个新的用户控件。将其命名为CardsInHandControl。

2.像这样向Grid中添加Label和Canvas控件:

1
2
3
4
5
6
7
8
9
<Grid>
    <Label Name="PlayerNameLabel" Foreground="White" FontWeight="Bold" FontSize="14" >
        <Label.Effect>
            <DropShadowEffect ShadowDepth="5" Opacity="0.5" Direction="145" />
        </Label.Effect>
    </Label>
    <Canvas Name="CardSurface">
    </Canvas>
</Grid>

3.转到代码隐藏文件,并使用以下指令进行使用:

1
2
3
4
5
6
7
8
using CardLib;
using System;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;

4.有四个依赖项属性。键入propdp并按Tab键插入属性模板。插入Type,Name,OwnerClass和默认值。使用选项卡从一个值切换到下一个值。设置值,如表15-5所示。完成值的编辑后,请按Return键以完成模板。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public Player Owner
{
    get { return (Player)GetValue(OwnerProperty); }
    set { SetValue(OwnerProperty, value); }
}

public GameViewModel Game
{
    get { return (GameViewModel)GetValue(GameProperty); }
    set { SetValue(GameProperty, value); }
}

public PlayerState PlayerState
{
    get { return (PlayerState)GetValue(PlayerStateProperty); }
    set { SetValue(PlayerStateProperty, value); }
}

public Orientation PlayerOrientation
{
    get { return (Orientation)GetValue(PlayerOrientationProperty); }
    set { SetValue(PlayerOrientationProperty, value); }
}

5.添加在Owner,PlayerState和PlayerOrientation的属性更改时将使用的回调方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
private static void OnOwnerChanged(DependencyObject source, DependencyPropertyChangedEventArgs e)
{
    var control = source as CardsInHandControl;
    control.RedrawCards();
}

private static void OnPlayerStateChanged(DependencyObject source, DependencyPropertyChangedEventArgs e)
{
    var control = source as CardsInHandControl;
    var computerPlayer = control.Owner as ComputerPlayer;
    if (computerPlayer != null)
    {
        if (computerPlayer.State == PlayerState.MustDiscard)
        {
            Thread delayedWorker = new Thread(control.DelayDiscard);
            delayedWorker.Start(new Payload
            {
                Deck = control.Game.GameDeck,
                AvailableCard = control.Game.CurrentAvailableCard,
                Player = computerPlayer
            });
        }
        else if (computerPlayer.State == PlayerState.Active)
        {
            Thread delayedWorker = new Thread(control.DelayDraw);
            delayedWorker.Start(new Payload
            {
                Deck = control.Game.GameDeck,
                AvailableCard = control.Game.CurrentAvailableCard,
                Player = computerPlayer
            });
        }
    }
    control.RedrawCards();
}

private static void OnPlayerOrientationChanged(DependencyObject source, DependencyPropertyChangedEventArgs args)
{
    var control = source as CardsInHandControl;
    control.RedrawCards();
}

6.回调需要许多帮助方法。首先在OnPlayerStateChanged方法中添加私有类和delayWorker线程使用的两个方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
private class Payload
{
    public Deck Deck { get; set; }
    public Card AvailableCard { get; set; }
    public ComputerPlayer Player { get; set; }
}

private void DelayDraw(object payload)
{
    Thread.Sleep(1250);
    var data = payload as Payload;
    Dispatcher.Invoke(DispatcherPriority.Normal, new Action<Deck, Card>(data.Player.PerformDraw), data.Deck, data.AvailableCard);
}

private void DelayDiscard(object payload)
{
    Thread.Sleep(1250);
    var data = payload as Payload;
    Dispatcher.Invoke(DispatcherPriority.Normal, new Action<Deck>(data.Player.PerformDiscard), data.Deck);
}

7.Owner属性需要一个回调,只要该属性发生更改,都应调用该回调。您可以将其指定为PropertyMetadata类的构造函数的第二个参数,该构造函数用作register()方法的第四个参数。像这样更改注册:

1
2
3
4
5
6
public static readonly DependencyProperty OwnerProperty =
    DependencyProperty.Register(
        "Owner",
        typeof(Player), 
        typeof(CardsInHandControl), 
        new PropertyMetadata(null, new PropertyChangedCallback(OnOwnerChanged)));

8.与Owner属性一样,PlayerState和PlayerOrientation属性也应注册一个回调。对这两个属性重复步骤7,对回调方法使用名称OnPlayerStateChanged和OnPlayerOrientationChanged。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public static readonly DependencyProperty PlayerStateProperty =
    DependencyProperty.Register(
        "PlayerState",
        typeof(PlayerState), 
        typeof(CardsInHandControl), 
        new PropertyMetadata(PlayerState.Inactive, new PropertyChangedCallback(OnPlayerStateChanged)));

public static readonly DependencyProperty PlayerOrientationProperty =
    DependencyProperty.Register(
        "PlayerOrientation", 
        typeof(Orientation), 
        typeof(CardsInHandControl),
        new PropertyMetadata(Orientation.Horizontal, new PropertyChangedCallback(OnPlayerOrientationChanged)));

9.添加用于绘制控件的方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
private void RedrawCards()
{
    CardSurface.Children.Clear();
    if (Owner == null)
    {
        PlayerNameLabel.Content = string.Empty;
        return;
    }
    DrawPlayerName();
    DrawCards();
}

private void DrawCards()
{
    bool isFaceup = (Owner.State != PlayerState.Inactive);
    if (Owner is ComputerPlayer)
        isFaceup = (Owner.State == PlayerState.Loser || Owner.State == PlayerState.Winner);
    var cards = Owner.GetCards();
    if (cards == null || cards.Count == 0)
        return;
    for (var i = 0; i < cards.Count; i++)
    {
        var cardControl = new CardControl(cards[i]);
        if (PlayerOrientation == Orientation.Horizontal)
            cardControl.Margin = new Thickness(i * 35, 35, 0, 0);
        else
            cardControl.Margin = new Thickness(5, 35 + i * 30, 0, 0);
        cardControl.MouseDoubleClick += cardControl_MouseDoubleClick;
        cardControl.IsFaceUp = isFaceup;
        CardSurface.Children.Add(cardControl);
    }
}
private void DrawPlayerName()
{
    if (Owner.State == PlayerState.Winner || Owner.State == PlayerState.Loser)
        PlayerNameLabel.Content = Owner.PlayerName + (Owner.State == PlayerState.Winner ? " is the WINNER" : " has LOST");
    else
        PlayerNameLabel.Content = Owner.PlayerName;
    var isActivePlayer = (Owner.State == PlayerState.Active || Owner.State == PlayerState.MustDiscard);
    PlayerNameLabel.FontSize = isActivePlayer ? 18 : 14;
    PlayerNameLabel.Foreground = isActivePlayer ? new SolidColorBrush(Colors.Gold) : new SolidColorBrush(Colors.White);
}

10.最后,添加在玩家双击卡片时调用的双击处理程序:

1
2
3
4
5
6
7
8
9
private void cardControl_MouseDoubleClick(object sender, MouseButtonEventArgs e)
{
    var selectedCard = sender as CardControl;
    if (Owner == null)
        return;
    if (Owner.State == PlayerState.MustDiscard)
        Owner.DiscardCard(selectedCard.Card);
    RedrawCards();
}

11.像在步骤1中一样创建另一个用户控件,并将其命名为GameDecksControl。

12.删除Grid并插入Canvas控件:

1
<Canvas Name="controlCanvas" Width="250" />

13.使用以下名称空间转到代码隐藏文件:

1
2
3
4
5
6
7
using CardLib;
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;

14.与在步骤4中所做的一样,使用这些值添加四个依赖项属性(请参见表15-6)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public bool GameStarted
{
    get { return (bool)GetValue(GameStartedProperty); }
    set { SetValue(GameStartedProperty, value); }
}

public Player CurrentPlayer
{
    get { return (Player)GetValue(CurrentPlayerProperty); }
    set { SetValue(CurrentPlayerProperty, value); }
}

public Deck Deck
{
    get { return (Deck)GetValue(DeckProperty); }
    set { SetValue(DeckProperty, value); }
}

public Card AvailableCard
{
    get { return (Card)GetValue(AvailableCardProperty); }
    set { SetValue(AvailableCardProperty, value); }
}

15.添加DrawDecks方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private void DrawDecks()
{
    controlCanvas.Children.Clear();
    if (CurrentPlayer == null || Deck == null || !GameStarted)
        return;
    List<CardControl> stackedCards = new List<CardControl>();
    for (int i = 0; i < Deck.CardsInDeck; i++)
        stackedCards.Add(new CardControl(Deck.GetCard(i))
        {
            Margin = new Thickness(150 + (i * 1.25), 25 - (i * 1.25), 0, 0),
            IsFaceUp = false
        });
    if (stackedCards.Count > 0)
        stackedCards.Last().MouseDoubleClick += Deck_MouseDoubleClick;
    if (AvailableCard != null)
    {
        var availableCard = new CardControl(AvailableCard)
        {
            Margin = new Thickness(0, 25, 0, 0)
        };
        availableCard.MouseDoubleClick += AvailalbleCard_MouseDoubleClick;
        controlCanvas.Children.Add(availableCard);
    }
    stackedCards.ForEach(x => controlCanvas.Children.Add(x));
}

16.在步骤14中添加的所有四个依赖项属性都需要在属性更改时使用回调方法。像在第5步中一样,将它们添加为名称OnGameStarted,OnPlayerChanged,OnDeckChanged和OnAvailableCardChanged。

17.添加回调方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private static void OnGameStarted(DependencyObject source, DependencyPropertyChangedEventArgs e) 
        => (source as GameDecksControl)?.DrawDecks();

    private static void OnDeckChanged(DependencyObject source, DependencyPropertyChangedEventArgs e) 
        => (source as GameDecksControl)?.DrawDecks();

    private static void OnAvailableCardChanged(DependencyObject source, DependencyPropertyChangedEventArgs e) 
        => (source as GameDecksControl)?.DrawDecks();

    private static void OnPlayerChanged(DependencyObject source, DependencyPropertyChangedEventArgs e)
    {
        var control = source as GameDecksControl;
        if (control.CurrentPlayer == null)
            return;
        control.CurrentPlayer.OnCardDiscarded +=
                        control.CurrentPlayer_OnCardDiscarded;
        control.DrawDecks();
    }

    private void CurrentPlayer_OnCardDiscarded(object sender, CardEventArgs e)
    {
        AvailableCard = e.Card;
        DrawDecks();
    }

18.最后,添加卡的事件处理程序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
void AvailalbleCard_MouseDoubleClick(object sender, MouseButtonEventArgs e)
{
    if (CurrentPlayer.State != PlayerState.Active)
        return;
    var control = sender as CardControl;
    CurrentPlayer.AddCard(control.Card);
    AvailableCard = null;
    DrawDecks();
}

void Deck_MouseDoubleClick(object sender, MouseButtonEventArgs e)
{
    if (CurrentPlayer.State != PlayerState.Active)
        return;
    CurrentPlayer.DrawCard(Deck);
    DrawDecks();
}

19.返回GameClientWindow.xaml文件并删除第2行中当前的Grid。而是插入一个新的DockPanel,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<DockPanel Grid.Row="2">
    <local:CardsInHandControl x:Name="Player2Hand" DockPanel.Dock="Right"
                                Height="380" Game="{Binding}"
                                VerticalAlignment="Center" Width="180" PlayerOrientation="Vertical"
                                Owner="{Binding Players[1]}" PlayerState="{Binding Players[1].State}" />
    <local:CardsInHandControl x:Name="Player4Hand" DockPanel.Dock="Left"
                                HorizontalAlignment="Left" Height="380" VerticalAlignment="Center"
                                PlayerOrientation="Vertical" Owner="{Binding Players[3]}" Width="180"
                                PlayerState="{Binding Players[3].State}" Game="{Binding}"/>
    <local:CardsInHandControl x:Name="Player1Hand" DockPanel.Dock="Top"
                                HorizontalAlignment="Center" Height="154" VerticalAlignment="Top"
                                PlayerOrientation="Horizontal" Owner="{Binding Players[0]}" Width="380"
                                PlayerState="{Binding Players[0].State}" Game="{Binding}"/>
    <local:CardsInHandControl x:Name="Player3Hand" DockPanel.Dock="Bottom"
                                HorizontalAlignment="Center" Height="154" VerticalAlignment="Top"
                                PlayerOrientation="Horizontal" Owner="{Binding Players[2]}" Width="380"
                                PlayerState="{Binding Players[2].State}" Game="{Binding}"/>
    <local:GameDecksControl Height="180" x:Name="GameDecks"
                            Deck="{Binding GameDeck}"
                            AvailableCard="{Binding CurrentAvailableCard}"
                            CurrentPlayer="{Binding CurrentPlayer}"
                            GameStarted="{Binding GameStarted}"/>
</DockPanel>

20.运行该应用程序。默认情况下,ComputerPlayer类处于启用状态,并且玩家数量设置为两个。这意味着您可以在“开始游戏”对话框中选择一个名称。之后,您应该能够看到如图15-5所示的内容。

双击卡片组或可用的卡片以抽出,然后从您的手中单击卡片以将其丢弃。最后通过抽换牌,先把所有手牌换成同一花色的玩家获胜:

代码原理

尽管此示例中有很多代码,但其中大多数是依赖项属性,而XAML都是关于数据绑定这些属性的。 CardsInHandControl创建了三个用于显示自身并对更改做出反应的属性:Game,Owner和PlayerState。 Game和Owner通常用于绘制,但是PlayerState也用于控制ComputerPlayer动作。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// CardsInHandControl.xaml.cs
private static void OnPlayerStateChanged(DependencyObject source, DependencyPropertyChangedEventArgs e)
{
    var control = source as CardsInHandControl;
    var computerPlayer = control.Owner as ComputerPlayer;
    if (computerPlayer != null)
    {
        if (computerPlayer.State == PlayerState.MustDiscard)
        {
            Thread delayedWorker = new Thread(control.DelayDiscard);
            delayedWorker.Start(new Payload
            {
                Deck = control.Game.GameDeck,
                AvailableCard = control.Game.CurrentAvailableCard,
                Player = computerPlayer
            });
        }
        else if (computerPlayer.State == PlayerState.Active)
        {
            Thread delayedWorker = new Thread(control.DelayDraw);
            delayedWorker.Start(new Payload
            {
                Deck = control.Game.GameDeck,
                AvailableCard = control.Game.CurrentAvailableCard,
                Player = computerPlayer
            });
        }
    }
    control.RedrawCards();
}

OnPlayerStateChanged方法(该方法用于对玩家状态的更改做出反应)确定当前玩家是否为ComputerPlayer。如果是这样,它将进行检查以确保电脑玩家能够抽出或丢弃卡。在这种情况下,它将为此创建一个工作线程delayedWorker并在该线程上执行方法。这允许应用程序在计算机等待时继续工作:

1
2
3
4
5
6
private void DelayDraw(object payload)
{
    Thread.Sleep(1250);
    var data = payload as Payload;
    Dispatcher.Invoke(DispatcherPriority.Normal, new Action<Deck, Card>(data.Player.PerformDraw), data.Deck, data.AvailableCard);
}

Dispatcher用于唤醒该调用。这样可以确保在GUI线程上进行调用。

绘制卡片非常简单。程序只是根据PlayerOrientation中的设置将它们垂直或水平堆叠。

GameDecksControl使用CurrentPlayer类来通知CurrentPlayer已更改。发生这种情况时,它将在玩家上挂起CardDiscarded事件,并使用此事件来通知该卡已被丢弃。

最后,您向游戏客户端添加了一个DockPanel,每侧都有一个CardsInHandControl,中间是一个GameDecksControl:

1
2
3
4
<local:CardsInHandControl x:Name="Player1Hand" DockPanel.Dock="Top"
                        HorizontalAlignment="Center" Height="154" VerticalAlignment="Top"
                        PlayerOrientation="Horizontal" Owner="{Binding Players[0]}" Width="380"
                        PlayerState="{Binding Players[0].State}" Game="{Binding}"/>

Game的绑定将游戏客户端的DataContext直接绑定到CardsInHandControl的Game属性。PlayerState绑定到玩家的State属性。在这种情况下,索引为0的玩家用于访问状态。

概要

话题 关键概念
Styles 您可以使用Style为XAML元素创建样式,这些样式可以在许多元素上重用。样式允许您设置元素的属性。当您将元素的Style属性设置为指向您定义的样式时,该元素的属性将使用您在Style属性中指定的值。
Templates 模板用于定义控件的内容。使用模板,您可以更改标准控件的显示方式。您也可以使用它们构建复杂的自定义控件。
User controls 用户控件用于创建代码和XAML,这些代码和XAML可在您自己的项目中轻松重用。此代码和XAML也可以导出以在其他项目中使用。