C#.NET 中的第 21 副纸牌魔术





5.00/5 (6投票s)
编写一个简单的纸牌魔术!
引言
21 副纸牌魔术是一个著名的纸牌魔术,使用 21 张牌进行表演。魔术师将牌分成 3 叠,然后根据包含目标牌的牌叠进行重新洗牌。这是一个有趣的演示项目,将这个魔术呈现在计算机程序中。
背景 - 纸牌魔术
我将不详细介绍这个魔术的细节,因为网上有很多关于如何表演这个魔术的教程。总而言之,魔术师首先将 21 张牌分成 3 叠。牌保持倒置,魔术师拿起最后一张牌,然后将其放在第一叠牌上,接着拿起下一张牌,放入第二叠牌,然后拿起下一张牌放入第三叠牌。下一张牌将放在第一叠牌上,以此类推。
洗牌后,魔术师逐一拿起牌叠,并询问观众目标牌是否在那叠牌中。无论哪一叠牌包含目标牌,该叠牌都会被放在另外两叠牌的中间。
然后魔术师以相同的方式重新洗牌。
经过 3 次洗牌后,魔术师开始从反面弹出牌,目标牌总是位于第 11 位。他/她向观众展示这张牌,让观众感到惊讶。
这张牌之所以总能放在第 11 位,有一个数学解释。一个快速而仔细的观察是,它位于最终合并牌叠的中间位置——前面 10 张牌,后面 10 张牌;目标牌自动就放在中间。
本文使用的关键词
- 牌叠 (Deck):一组牌
- 牌面 (Card-front):牌上显示实际点数的一面
- 牌背 (Card-back):牌上不显示任何东西的一面;所有牌上都印有相似的图案作为背景图像
- 魔术师 (Magician):表演者
- 观众 (Observer):选择牌的人
Using the Code
这个项目使用了一些数据结构, namely – List
, KeyValuePair
, HashTable
and Stack
。魔术包含以下步骤:
- 从资源文件中加载牌。
- 随机将牌加载到 picturebox 中。
- 允许用户选择牌;他/她通过点击包含该牌的按钮来确认。
- 在用户选择行后,将该行放在另外两行中间。
- 允许用户选择 3 次。在第 3 次重新洗牌后,开始弹出牌并显示从反面(或正面)数的第 11 张牌(此时从哪一边数都可以,因为目标牌总是在中间)。
1) 加载牌
在设计时,使用 picturebox,并通过内置的 IDE 助手选择图像。
通过这样做,图像被加载到 picturebox 中,IDE 会自动将图像保存在资源文件中。
使用两个对象来存储牌——一个类型是 stack(‘ShuffledCards
’),另一个(‘Cards
’)是 list 类型。两者都将牌存储为 KeyValuePair
对象。KeyValuePair
对象存储一个对象及其名称。例如: KeyValuePair
for Diamond Ace: {“Diamond Ace”, Image of diamond ace}。
想法是随机加载图像到 picturebox。首先,所有资源都加载到一个 ResourceSet
对象中。然后,所有资源被恢复到 ‘Cards
’ 列表中。
ResourceSet resourceSet =
Properties.Resources.ResourceManager.GetResourceSet(CultureInfo.CurrentUICulture, true, true);
foreach (DictionaryEntry entry in resourceSet)
//Cards.Add(entry.Value);
Cards.Add(new KeyValuePair<string, object>(entry.Key.ToString(), entry.Value));
2) 随机将牌加载到 Picture Boxes 中
加载图像资源后,我们选择随机放置图像到 picturebox 中。初始化随机数生成器。生成一个(1 到 21 之间(包括 1 和 21))的随机索引。请注意,如果您指定范围(1 到 22),它将生成一个介于 1 和 21 之间的随机数。实际上,我们需要一个介于 (0 到 20) 之间的索引,但由于某些原因 0 从未生成,所以我 resorted to this solution of generating between (1 and 21 inclusive) and then deduct 1 to obtain the actual index。(这部分英文的描述有点绕,大概意思是想生成 0-20 的索引,但直接生成 1-21,然后减 1 来获得需要的索引。)
picturebox 被命名为 ‘picturebox0
’, ‘picturebox1
’, ‘picturebox3
’……依此类推,直到 ‘picturebox20
’。
Random Rnd = new Random(DateTime.Now.Millisecond);
int Idx;
Hashtable HashTbl = new Hashtable();
...................
...................
for (int i = 0; i < 21; i++)
{
Idx = Rnd.Next(1, 22);
while (ThisImageWasAlreadyTaken(--Idx, HashTbl))
Idx = Rnd.Next(1, 22);
HashTbl.Add(Idx, Idx);
...................
...................
hashtable 用于检查索引是否已被占用。ThisImageWasAlreadyTaken()
方法根据哈希表中索引的可用性简单地返回 true
/false
。
private bool ThisImageWasAlreadyTaken(int Idx, Hashtable HashTbl)
{
return HashTbl.ContainsKey(Idx);
}
picturebox 在设计表单中的布局如下。现在是时候通过名称查找 picturebox 并放置随机图像了。这在下面的代码中完成。请注意,Controls.Find()
方法根据提供的 string
返回控件。第二个参数指示是否也应该搜索子控件。这设置为 ‘false
’,因为我们知道没有子控件。
Control[] PicBox;
PicBox = Controls.Find("PictureBox" + i, false);
(PicBox[0] as PictureBox).Image = (Image)Cards[Idx].Value;
ShuffledCards.Push(Cards[Idx]);
上面代码的最后一行是堆栈操作的 push。请注意,C# 的 push-pop 操作方式与标准方式相同,但其存储值的方式需要稍微注意。
例如,对于上面图像所示的第一次加载,ShuffledCards
堆栈将包含:
[0]: {[Spade Jack, System.Drawing.Bitmap]}
[1]: {[Diamond 10, System.Drawing.Bitmap]}
[2]: {[Spade Ace, System.Drawing.Bitmap]}
[3]: {[Club Queen, System.Drawing.Bitmap]}
[4]: {[Heart 10, System.Drawing.Bitmap]}
[5]: {[Heart Queen, System.Drawing.Bitmap]}
[6]: {[Spade Queen, System.Drawing.Bitmap]}
[7]: {[Diamond Queen, System.Drawing.Bitmap]}
[8]: {[Club 10, System.Drawing.Bitmap]}
[9]: {[Diamond Jack, System.Drawing.Bitmap]}
[10]: {[Diamond Ace, System.Drawing.Bitmap]}
[11]: {[Spade King, System.Drawing.Bitmap]}
[12]: {[Heart Jack, System.Drawing.Bitmap]}
[13]: {[Heart King, System.Drawing.Bitmap]}
[14]: {[Diamond King, System.Drawing.Bitmap]}
[15]: {[Club Ace, System.Drawing.Bitmap]}
[16]: {[Club Jack, System.Drawing.Bitmap]}
[17]: {[Heart Ace, System.Drawing.Bitmap]}
[18]: {[Club King, System.Drawing.Bitmap]}
[19]: {[Spade 2, System.Drawing.Bitmap]}
[20]: {[Spade 10, System.Drawing.Bitmap]}
也就是说,它会堆叠在上面(如果我们想象一个垂直的堆栈)。我们第一个 push 的是‘Spade 10
’,它被 push 到第一个索引,但是当我们继续 push 其他东西时,它最终会移动到最后一个索引。总之,索引不是我们关心的——重要的是堆栈中的 push/pop 操作。我只是想稍微说明一下 C# 中堆栈的工作方式。堆栈没有索引操作(尽管可以使用 LINQ 扩展方法 ElementAt()
,但这并不是堆栈的灵魂方法)。
3) 用户交互
在观众选择一张牌并点击行按钮后,当前洗牌后的牌会被复制到一个名为‘OldShuffledCards
’的临时列表中。然后,正如纸牌魔术所说,包含目标牌的行将放在另外两行中间。如果目标牌在第一行,则通过以下行完成。
现在让我们看看这是如何实现的。如果我们看上面的分布,假设观众选择‘Heart Ace’(即从左边算起,顶行中的第二张牌),那么它就在第一行。所以,严格来说,这一行的所有牌都会移动到另外两行之间。这通过 3 个连续的循环来完成。
第一个循环 push 第二行的所有牌。如果我们仔细观察,在第一次迭代中,push 了 OldShuffledCards[1]
,根据上面的分布,它是‘Diamond 10
’。同样,它 push 了这一行的所有牌。
下一个循环 push 第一行的所有牌。如果我们仔细观察,在第一次迭代中,push 了 OldShuffledCards[2]
,根据上面的分布,它是‘Spade Ace
’。同样,它 push 了这一行的所有牌。
下一个循环 push 第三行的所有牌。如果我们仔细观察,在第一次迭代中,push 了 OldShuffledCards[0]
,根据上面的分布,它是‘Spade Jack
'。同样,它 push 了这一行的所有牌。
这样,目标行就被放在了中间。正如预期的那样,第一个和第三个循环可以互换——重要的是将目标行放在中间。
for (int i = 0; i < 7; i++)
ShuffledCards.Push(OldShuffledCards[1 + 3 * i]);
for (int i = 0; i < 7; i++)
ShuffledCards.Push(OldShuffledCards[2 + 3 * i]);
for (int i = 0; i < 7; i++)
ShuffledCards.Push(OldShuffledCards[3 * i]);
同样,如果目标牌在第 2 行,则重新分配如下:
for (int i = 0; i < 7; i++)
ShuffledCards.Push(OldShuffledCards[3 * i]);
for (int i = 0; i < 7; i++)
ShuffledCards.Push(OldShuffledCards[1 + 3 * i]);
for (int i = 0; i < 7; i++)
ShuffledCards.Push(OldShuffledCards[2 + 3 * i]);
同样,如果目标牌在第 3 行,则重新分配如下:
for (int i = 0; i < 7; i++)
ShuffledCards.Push(OldShuffledCards[2 + 3 * i]);
for (int i = 0; i < 7; i++)
ShuffledCards.Push(OldShuffledCards[3 * i]);
for (int i = 0; i < 7; i++)
ShuffledCards.Push(OldShuffledCards[1 + 3 * i]);
重新分配后,我们需要将牌重新洗入 3 叠牌中。
为了重新分配,首先创建一个名为‘TempCards
’的克隆堆栈。这个堆栈是我们全局 ShuffledCards
的克隆。然后,ShuffledCards
被清空。接着,通过从 TempCards
堆栈中弹出最后一张牌来填充所有 picturebox(从第 0 张到第 20 张)。同时,弹出的牌被 push 到我们的全局 ShuffledCards
中,这将在后续的洗牌中用到。这在下面的代码中完成。
Stack<KeyValuePair<string, object>> TempCards =
new Stack<KeyValuePair<string,
object>>(new Stack<KeyValuePair<string, object>>(ShuffledCards));
ShuffledCards.Clear();
Control[] PicBox;
for (int i = 0; i < 3; i++)
for (int j = 0; j < 7; j++)
{
PicBox = Controls.Find("PictureBox" + (j + (i * 7)), false);
KeyValuePair<string, object> KVP = TempCards.Pop();
ShuffledCards.Push(KVP);
if (ShuffleCount < 2)
{
Thread.Sleep(20);
(PicBox[0] as PictureBox).Image = (Image)KVP.Value;
Application.DoEvents();
}
}
这是在前两次洗牌中完成的。故意加入了一点延迟(Thread.Sleep(20);
),以便可以在屏幕上清晰地观察到洗牌过程。
那么最后一次重新洗牌呢?这也进行了,但方式很巧妙。如果我们省略这部分并移除上面的 IF
条件,那么观众将总是能看到目标牌总是位于任一方向的第 10 位。所以为了迷惑他们的理解,最后一次重新洗牌是在‘Cards
’堆栈上完成的,该堆栈最初存储着来自资源文件的牌。这实际上是从我们熟悉的 Cards
加载图像,而不是从 ShuffledCards
(此时它已经将目标牌放在了第 11 位)。请注意,最后一次重新洗牌也在上面的代码中完成;只是在图像加载方面我们稍微有点狡猾。
if (ShuffleCount == 2)
{
Hashtable HashTbl = new Hashtable();
int Idx;
Random Rnd = new Random(DateTime.Now.Millisecond);
for (int i = 0; i < 3; i++)
for (int j = 0; j < 7; j++)
{
PicBox = Controls.Find("PictureBox" + (j + (i * 7)), false);
Idx = Rnd.Next(1, 22);
while (ThisImageWasAlreadyTaken(--Idx, HashTbl))
Idx = Rnd.Next(1, 22);
HashTbl.Add(Idx, Idx);
(PicBox[0] as PictureBox).Image = (Image)Cards[Idx].Value;
Thread.Sleep(20);
Application.DoEvents();
}
}
最后,如果洗牌计数达到 3,则显示第 11 张牌。
private void DisplayCard()
{
Control[] PicBox = Controls.Find("ChosenCardpictureBox", false);
for (int i = 0; i < 10; i++)
ShuffledCards.Pop();
KeyValuePair<string, object> KVP = ShuffledCards.Pop();
(PicBox[0] as PictureBox).Image = (Image)KVP.Value;
PlayAgainButton.Visible = true;
StatusLabel.Text = "Look I found your card!...... Magic!!";
Row1Button.Enabled = Row2Button.Enabled = Row3Button.Enabled = false;
}
环境
该项目在 Visual Studio 2015 IDE 中完成,目标是 .NET Framework 4.5。
未来工作
可以应用动画效果,将牌从其位置移动到合并的牌叠,然后再次分发到单独的牌叠。在各自的牌叠内部也可以随机化,以向观众展示更令人费解的效果(也就是说,在每一行中,7 张牌可以随机放置,但必须在单独的数组中维护原始顺序)。
替代数据结构
键值对不是必须的组件,可以使用简单的列表,因为没有使用纸牌名称。使用它是为了更有效地展示项目。使用的是图像,可以将它们放入列表中并加以利用。
可以使用简单的列表代替 Stack
。
参考文献
历史
- 2016 年 11 月 11 日:首次发布