Wordbler 女士——一只可爱的小莺!





5.00/5 (7投票s)
单词制作游戏!
引言
这是一个单词拼写游戏;这是一个 C#.NET 的 Winforms 项目,使用 .NET Framework 4.5.2。IDE 是 Visual Studio 2017。
该应用程序被构建为与分辨率无关。设计时分辨率为 1680x1050。它在 1366x768、1280x720、800x600 的分辨率下进行了测试。测试通过,但图形质量在较低分辨率下会下降(如预期)。
字母、方块、单元格——在本文中都可互换使用;它们表示相同的意思。
背景
世界各地有各种单词拼写游戏,包括填字逻辑。这个游戏借鉴了其中一些想法。目的是通过实验来开发关于如何实际思考数据结构和算法以及如何在字符串中找到结构模式的逻辑。这纯粹是为了教育目的,并带有一些乐趣。
游戏玩法
- 游戏开始时会询问玩家数量。最多 4 名玩家,最少 2 名玩家。玩家需要输入他们的名字并选择吉祥物(将吉祥物拖放到各自的吉祥物框中)。
- 接下来决定哪个玩家开始游戏。每个玩家会随机从袋子里分到一个字母。如果任何玩家拿到一张空白牌,那么他/她将开始游戏。否则,字母最接近“A”的玩家将开始游戏。第一个玩家确定后,所有字母都会返回到袋子里。
- 接下来,牌架上会装入袋子中随机的 7 个字母。
- 玩家将单个字母拖放到棋盘上。第一个单词必须穿过中心星号 (*) 方块。
- 玩家认为单词完成后,他/她按下**完成**按钮。此时,会出现一个包含得分详情的消息框。如果所有创造的单词都有效,则他/她得分。否则,字母将返回到他/她的牌架上,回合移至下一位玩家。
- 回合按顺时针方向移至下一位玩家,游戏继续进行。
- 玩家可以从袋子中交换任意数量的字母。交换会放弃他/她当前的回合。要交换,按住 **CTRL** 按钮并单击要交换的字母(方块变为深红色),然后按下 **交换** 按钮。
- 如果玩家无法组成单词,他/她可以放弃回合。不过这并非必需,因为如果单词无效,游戏会自动放弃当前回合。
- 如果玩家拖动一张空白牌,则当它被放到棋盘上时,牌的值应被设置。牌单元格可以通过粉红色识别。该值在游戏的其余部分保持不变。但是,该牌总是获得 0 分。
- 如果形成了一个穿过奖励单元格(2W、2L、3W、3L)的有效单词,那么在得分后,这些特殊方块将变为普通方块——它们将不再增强任何穿过它们的未来单词。这通过将其特殊背景颜色更改为灰色来表示。
- 如果玩家不小心将字母放在棋盘的错误单元格上,他/她可以按 **CTRL+Z** 取回。在当前回合中,玩家可以根据需要多次按 **CTRL+Z**。这将以拖动相反的顺序将字母带回牌架。
- 在任何时候,连续 6 次失败(错误单词、放弃、交换)将结束游戏。在这种情况下,将每个玩家牌架上剩余字母的面值总和从他/她的总分中扣除。
- 如果袋子里没有更多字母,那么
- 如果其中一名玩家用完了他/她所有的字母,那么其他玩家的字母总和将加到他/她的分数中,并从其他玩家的分数中扣除。
- 否则,将每个玩家牌架上剩余字母的面值相加,并从每个玩家的总分中扣除该总和。
-
允许单词的扩展形式。例如,如果玩家组成单词“`END`”,那么另一个玩家可以将其扩展为“`ENDING`”,再另一个玩家可以将其扩展为“`ENDINGS`”。所有这些都是有效单词。
-
之后,得分最高的玩家赢得游戏。
项目结构一瞥
表单
选择玩家
这是游戏的启动表单。它与主要逻辑无关紧要。它只是为游戏增添了一点魅力。
在此表单中,玩家需要输入他们的名字并将吉祥物拖到他们的吉祥物框中。
Wordbler
这是进行游戏的主表单。它包含棋盘、牌架、玩家姓名、图例、失败次数、吉祥物、分数和按钮。
加载时,表单会显示所有可用玩家的牌架。字母袋会晃动并随机向每个玩家分发一个字母。这个字母决定哪个玩家应该开始游戏。请记住,抽到最接近“`A`”的字母或抽到空白牌的玩家开始游戏。
第一个玩家确定后,字母被放回袋子里,然后随机将 7 个字母分发给每个玩家。起始玩家的牌架被启用,其他玩家的牌架被禁用。玩家将字母拖放到棋盘上。
有三个按钮——“**完成**”、“**交换**”和“**跳过**”。如果玩家想提交单词进行评分,则他/她按下“**完成**”。如果玩家想交换字母,则他/她按下“**交换**”(要交换,需要按住 **CTRL** 键的同时单击字母来选择字母)。如果玩家决定跳过,则他/她按下“**跳过**”。
使用菜单,图例可以显示或隐藏。高级内容(3L、2L、3W、2W)根据切换状态隐藏或显示。
游戏随回合进行。
通配符选择器
当玩家将通配符(由兰花色方块表示)拖到棋盘上时,此表单会弹出,并要求用户为其选择一个字母。如果单词拼写成功,则通配符会在棋盘上保留该字母。然而,通配符的得分为 0 分,并且在游戏结束前始终为 0 分。通配符将以粉红色识别。
显示分数详情
当玩家点击任意吉祥物时,此表单会显示拥有该吉祥物的玩家的得分历史。它会遍历详细历史并按回合显示。
关于
“**关于**”框——显示有关项目的信息。
类
全局变量
包含项目不同模块所需的 `global` 和 `static` 变量。
棋盘单元格
包含放置字母的棋盘单元格的 (X, Y) 坐标。它还包含有关放置前棋盘单元格可能具有的任何高级内容(2W、3W、2L、3L)的信息。它包含放置在单元格上的字母。这些属性通过构造函数填充。
牌架单元格
当玩家开始从牌架拖动字母时,它会保存牌架单元格的详细信息。它记录了玩家编号、单元格编号以及该单元格上的字母。这些属性通过复制构造函数、常规构造函数和“`Add`”方法填充。
信
保存一个字母及其位置。该字母可能是一个交换字母、一个抽出的字母,或者一个袋子里的字母。位置是字母在袋子中的索引。这两个属性通过构造函数填充。
个人分数
此类包含单词中每个字母的计分机制。它包含字母、字母在棋盘上的轴、棋盘可能具有的任何高级内容(2W、3W、2L、3L)以及字母的分数。字母根据预定义规则具有其面值。
带有分数的有效词
包含当前回合中创造的整个单词,单词的单个字母得分详情(使用“`IndividualScore`”对象列表),以及单词在棋盘上开始的轴。
回合与分数
包含回合数、详细分数(与按下“**完成**”按钮时显示的消息完全相同)以及该回合的有效词。所有属性均通过构造函数填充。
玩家详情
包含玩家的详细信息(姓名、吉祥物)、他们的总分以及当前游戏的历史得分详细信息(使用“`TurnsWithScores`”对象)。姓名和吉祥物通过构造函数填充。总分和历史得分详细信息是 `public` 属性——因此通过对象实例填充。
窗口缩放器
此类有助于根据不同的屏幕分辨率自动调整表单元素的大小。游戏是分辨率独立的;此类包含为此目的的核心计算。对此类的详细解释超出了本文的范围。请参阅此处。
游戏板设计
棋盘上的每个控件都从 0 开始命名,以避免混淆。
架子
对于玩家 1,有 7 个方块,它们基本上是 7 个标签控件。这些方块放置在一个面板控件内。
它们被命名为 `p0l0`、`p0l1` …… `p0l6`。“`p`”代表“`player`”,“`l`”代表 `label`。0-based 索引被用作命名约定,它提供了稍后将讨论的映射好处。
同样,其他玩家的方块也被命名。例如:`p1l0`、`p1l1` 等。
有 4 个面板用于 4 位玩家,总共有 7x4 = 28 个标签控件用于所有玩家。
板
棋盘设计如下。
从左到右视为 X 轴,从上到下视为 Y 轴。这也基于 0,同样提供了稍后将讨论的映射优势。
对于第一行,标签设计如下。“`l`”代表 `label`。
l0_0, l1_0, l2_0, …..... l14_0。
这种命名的意图是每个标签都映射到矩阵中对应的单元格。例如,`l4_12` 对应于矩阵中的单元格 [4,12]。值得一提的是,矩阵是验证单词的实际计算方法。
加载(显示)玩家
设计中所有四个玩家都已就位;最初它们都处于隐藏状态。只是可见性逻辑根据玩家数量来显示它们。这是在“`LoadPlayers`”方法中完成的。它遍历所有可用玩家 (`Players.Count`) 并使其相应的牌架、吉祥物和分数控件可见。
Control ctl;
for (int i = 0; i < Players.Count; i++)
{
ctl = Controls.Find($"rack{i}", true)[0]; // E.g.: rack0.
ctl.Visible = true;
……………………………………………..
……………………………………………..
决定第一个玩家
第一个玩家是通过从袋子中随机抽取一个字母给每个玩家来决定的。它加载到每个玩家牌架的第一个单元格中。抽到最接近“`A`”的字母或抽到空白牌的玩家将开始游戏。这在“`ResolveFirstPlayer`”方法中完成。
以下段落首先检查是否有人抽到了空白牌。如果有人抽到空白牌,则它会直接从这里返回玩家编号。他/她将是第一个玩家。
// If any player draws a blank tile, s/he will be the first to start.
// Visibility confirms if the player is available. Just to remind, if 3 players were to play,
// then p3l0 will not be visible, hence will be out of context and consideration.
if (p0l0.Visible && string.IsNullOrEmpty(p0l0.Text))
return 0;
if (p1l0.Visible && string.IsNullOrEmpty(p1l0.Text))
return 1;
if (p2l0.Visible && string.IsNullOrEmpty(p2l0.Text))
return 2;
if (p3l0.Visible && string.IsNullOrEmpty(p3l0.Text))
return 3;
如果没有人抽到空白牌,那么下一步是决定谁抽到的字母最接近“`A`”。这很简单——只需计算距离并决定哪个最小。
我们从一个较高的值(999)开始。最大距离可能是(Z-A)= 25。所以,任何大于 25 的数字都可以。这是一个简单的算术——任何小于前一个的距离都被指定为当前最小值。最终获得具有最小距离的玩家。这是一个单次遍历,因此是 O(n)。
int min = 999, distance; // Start with a higher number.
int minWinner = -1; // Any number other than 0,1,2,3 will do.
for (int i = 0; i < Players.Count; i++) // Loop through all players.
{
ctl = Controls.Find($"p{i}l0", true)[0]; // E.g.: p2l0.
distance = ctl.Text.ToCharArray()[0] - 'A';
if (distance < min) // E.g.: if text of p2l0 is 'E', then distance is 4.
{
min = distance; // then make min the current distance,
minWinner = i; // and change the minWinner to the current player.
}
}
如果多于一名玩家抽到与“`A`”最接近的相同字母,则在顺时针方向上最早的玩家赢得掷骰。
激活/停用玩家
这很简单——只需遍历所有玩家,激活当前玩家并停用其他玩家。玩家编号作为参数传入。只有该玩家会被启用,其他玩家会被禁用。
这是通过“`ActivateDeactivatePlayers`”方法实现的。启用和可见性通过布尔检查来切换,以判断当前玩家是否为当前循环变量。
private void ActivateDeactivatePlayers(int player)
{
……………………………………………..
……………………………………………..
// Loop through all players.
for (int i = 0; i < Players.Count; i++)
{
……………………………………………..
……………………………………………..
// If current player's turn, then enable name and rack.
rackCtl.Enabled = scoreCtl.Enabled = i == player;
// And make the green button visible.
turnButtonCtl.Visible = i == player;
}
这无论如何都可以是一个语句,只是为了理解而分开。否则,它也可以很简单,例如
for (int i = 0; i < Players.Count; i++)
{
……………………………………………..
……………………………………………..
rackCtl.Enabled = scoreCtl.Enabled = turnButtonCtl.Visible = i == player;
}
袋子里装字母
袋子中每个字母的数量根据预定义的规则确定。例如,袋子中应该正好有 4 个“`D`”——不多,不少。这是通过循环从 65(“`A`”的 ASCII)到 90(“`Z`”的 ASCII)来实现的。对于每个 ASCII 值,相应的字母按顺序添加到字母袋中。请记住,在这一点上它们是否按顺序并不重要,因为它们在游戏过程中是随机选择的。
两个空白方块手动添加到最后两个索引中。
public void LoadLetterBag()
{
for (int letter = 65, i = 0; letter < 91; letter++)
switch (letter)
{
case 65: // Should be 9 'A'
for (int count = 0; count < 9; count++)
LetterBag[i++] = (char)letter;
break;
case 66: // Should be 2 'B'
case 67: // Should be 2 'C'
case 70: // Should be 2 'F'
case 72: // Should be 2 'H'
case 77: // Should be 2 'M'
case 80: // Should be 2 'P'
case 86: // Should be 2 'V'
case 87: // Should be 2 'W'
case 89: // Should be 2 'Y'
for (int count = 0; count < 2; count++)
LetterBag[i++] = (char)letter;
break;
……………………………………………..
……………………………………………..
……………………………………………..
}
LetterBag[98] = LetterBag[99] = ' '; // Two blank tiles.
}
将字母加载到牌架中
字母通过从袋子(“`LetterBag`”数组)中随机选择字母来加载。获得一个随机索引。如果该索引中的字母为 `null`(“`\0`”),则循环继续,并获得另一个索引。
private void LoadSingleLetter(int player, int cellNum)
{
………………………….
………………………….
while (true)
{
index = Rnd.Next(0, NUM_LETTERS_IN_BAG);
c = LetterBag[index];
if (c == '\0')
continue;
………………………….
………………………….
LetterBag[index] = '\0'; // The letter at the index is taken. Hence make it empty.
break;
}
}
需要注意的是 `Rnd.Next()` 的下限是包含的,而上限是排他的。这意味着,随机器选择的数字比给定的上限小一个。我们的 100 字母数组的下限为 0,上限为 100 (`NUM_LETTERS_IN_BAG`);因此,随机器正确地选择了 0 到 99(含)之间的索引。需要注意的是,`NUM_LETTERS_IN_BAG` 是一个随着时间变化的全局变量,表示当前的上限。
拖放
游戏使用拖放事件将字母放置在棋盘上。当鼠标开始从牌架拖动字母时,该字母会触发鼠标按下事件。当字母被拖动到棋盘上的单元格上时,该棋盘单元格会触发拖动进入事件。当鼠标最终将字母放到棋盘单元格上时,该单元格会触发拖放事件。
现在让我们深入探讨每个事件中发生的事情。
开始拖动
假设正在从第一个玩家牌架的第一个单元格拖动一个字母。
以下是第一个玩家牌架第一个单元格的“`MouseDown`”事件。第一行将字母添加到变量“`Source`”中。第二行开始一个拖放操作,其中包含该单元格上的字母。
private void p0l0_MouseDown(object sender, MouseEventArgs e)
{
string letter = AddSource(sender as Label);
(sender as Label).DoDragDrop(letter, DragDropEffects.Copy);
}
通用的“`AddSource`”方法会解析标签,以获取玩家的编号、鼠标按下时他/她牌架上的单元格以及该单元格上的字母。如果标签名称为“`p0l3`”,则玩家编号为 `0`(第一个玩家),单元格编号为 `3`。
private string AddSource(Label sender)
{
string letter = sender.Text;
string name = sender.Name;
int player = Convert.ToInt16(name.Substring(1, 1));
int cellInRack = Convert.ToInt16(name.Substring(name.IndexOf("l") + 1));
Source.Add(player, cellInRack, letter);
return letter;
}
这是一个非常简单的字符串操作。这是一个所有 28 个鼠标按下事件(4 个玩家 x 每个 7 个单元格)都会调用的通用方法。
总而言之,此方法跟踪鼠标拖动开始的源单元格详细信息。
拖拽进入
当玩家将拖动的鼠标移动到棋盘上的单元格时,会发生拖动进入事件。这只是指示在拖放操作完成后,字母将从源复制。
private void l0_0_DragEnter(object sender, DragEventArgs e)
{
e.Effect = DragDropEffects.Copy;
}
棋盘上所有 225 个单元格(15 x 15)需要有 225 个“`DragEnter`”事件。每个事件都需要放置相同的行。不幸的是,没有机制可以一次性分配它们以避免 225 个这样的事件。
掉落
当玩家最终在棋盘上的一个单元格上释放鼠标按钮时,发生拖放事件。
private void l0_0_DragDrop(object sender, DragEventArgs e)
{
ProcessDragDrop(sender as Label, e);
}
该事件调用通用方法“`ProcessDragDrop`”。
private void ProcessDragDrop(Label boardCell, DragEventArgs e)
{
string name = boardCell.Name;
int x = Convert.ToInt16(name.Substring(1, name.IndexOf("_") - 1));
int y = Convert.ToInt16(name.Substring(name.IndexOf("_") + 1));
Control rackCell = Controls.Find($"p{Source.Player}l{Source.Cell}", true)[0];
……………………………………………………
……………………………………………………
}
此方法将标签(鼠标释放的位置)分离以获取轴。此外,它还从鼠标拖动开始的源标签 (`rackCell`) 获取。
例如,如果棋盘单元格是“`l12_14`”,那么鼠标释放的单元格的轴是:x = 12,y = 14。
接下来,以下行检查源单元格中是否有任何内容。从消息中可以看出,这只有在玩家忘记尝试拖动他在当前回合中已经拖过的相同字母时才可能发生。
if (rackCell.Text.ToCharArray().GetUpperBound(0) == -1)
{
MessageBox.Show("Can't drag this tile.
It was already dragged before; there is nothing in it.");
return;
}
之后,以下行检查放置字母的单元格是否已经包含另一个字母。
else if (matrix[x, y] != '\0')
MessageBox.Show("Occupied. Can't place here.");
然后它检查拖动的字母是否是空白牌。提醒一下,袋子里有两张空白牌。如果它是一张空白牌,它会允许玩家为该牌选择一个字母(`WildCardChooser` 表单)。该表单将用户输入的选定通配符设置为已输入。
if (Source.Letter == ' ')
{
WildCardChooser card = new WildCardChooser(this);
card.Top = Cursor.Position.X;
card.Left = Cursor.Position.Y;
card.ShowDialog();
if (WildCard == '\0')
{
MessageBox.Show("You didn’t choose any letter for the wild card.
Choose a letter for the wild card or proceed with another letter in your rack.");
return;
}
// This contains special contents (if any)
// of the board before dragging. E.g.: 2W, 3L etc.
string specialCellContent = boardCell.Text;
RackCellList.Push(new RackCell(Source.Player, Source.Cell, WildCard));
boardCell.Text = WildCard.ToString();
BoardCellList.Push(new BoardCell(x, y, specialCellContent,
WildCard.ToString(), boardCell.BackColor));
rackCell.Text = string.Empty;
// After putting the wildcard letter, set the global variable to NULL
// to be used for the second wildcard.
WildCard = '\0';
}
“`RackCellList`”是一个堆栈,包含当前回合中拖动的单元格详细信息(在牌架中)。“`BoardCellList`”是一个堆栈,包含鼠标放置字母的棋盘单元格详细信息。堆栈有三个用途。
- 如果用户不小心将字母放置在错误的单元格中,则协助使用“**Ctrl+Z**”。
- 协助评分。
- 如果单词无效,则协助撤回字母。
最后,如果是一个常规字母(从“A”到“Z”的任何字母)拖放,那么它只是将字母推入各自的堆栈。
// This contains special contents (if any) of the board before dragging. E.g.: 2W, 3L etc.
string specialCellContent = boardCell.Text;
boardCell.Text = (string)e.Data.GetData(DataFormats.Text);
BoardCellList.Push(new BoardCell
(x, y, specialCellContent, boardCell.Text, boardCell.BackColor));
matrix[x, y] = boardCell.Text.ToCharArray()[0];
RackCellList.Push(new RackCell(Source));
rackCell.Text = string.Empty;
总之,棋盘的 225 个单元格(15 x 15)有 225 个拖动进入事件和 225 个拖放事件。
交换字母
选择字母
玩家可以在当前回合中交换部分或全部字母(前提是袋子里至少有 7 个字母)。在这种情况下,用现有字母无法组成有效单词,玩家希望尝试不同的字母。交换后,当前回合作废,下一位玩家继续。
首先选择字母,然后按“**交换**”按钮以促进交换。如果用户按 **CTRL** 键,然后单击一个字母,该字母将被添加到“`Letters`”列表中。字母背景变为 `Crimson`,表示它已被选中。如果一个字母已被选中(`Crimson`),并且玩家在按 **CTRL** 键的同时单击该字母,则会撤销选择——该字母将从列表中删除,并且方块颜色将恢复为正常控件颜色(`SystemColors.Control`)。
List<Letters> ExchangingLetters = new List<Letters>();
private readonly Color EXCHANGE_LETTER_CELL_COLOR = Color.Crimson;
private void AddLettersToExchangeTotheList(Label label)
{
// If the label's back colour is already crimson,
// then it removes the letter from the list.
if (label.BackColor == EXCHANGE_LETTER_CELL_COLOR)
{
// Change the colour back to system control colour.
label.BackColor = SystemColors.Control;
// Remove the letter from the exchanging list.
ExchangingLetters.Remove(ExchangingLetters.Find
(x => x.Letter == label.Text.ToCharArray()[0]));
}
else
{
int pos = Convert.ToInt16(label.Name.Substring(3));
ExchangingLetters.Add(new Letters(label.Text.ToCharArray()[0], pos));
label.BackColor = EXCHANGE_LETTER_CELL_COLOR;
}
}
交换字母
当玩家按下“**交换**”时,“`ExchangingLetters`”列表中的字母将返回到袋子中,新的字母将被取入,并且深红色将被常规控制颜色替换。新的随机字母将被抽取。(如果返回相同的字母,请不要责怪游戏。我记得《憨豆先生》中的一集,他试图退回食物,但又被带回了同一件物品到他的桌子上 :))。
在交换之前,会进行一次小检查,看用户在当前回合是否在棋盘上放置了任何字母。在棋盘上放置字母和选择交换是互斥的——它们不能同时发生。在这种情况下,会显示一条消息并且不进行交换。如果玩家真的选择交换,那么字母首先应该被取回牌架(**CTRL+Z**)。通过维护两个用于移动的列表来检查玩家是否在棋盘上放置了字母。逻辑上检查其中一个即可,因为两者将具有相同数量的条目。
if (RackCellList.Count > 0 || BoardCellList.Count > 0)
{
MessageBox.Show("You already dragged letters on the board.
Can't exchange now. First return the letters (CTRL+Z).");
return;
}
如果此检查通过,则字母将飞回袋子中。
foreach (Letters l in ExchangingLetters)
{
ctl = Controls.Find($"p{CurrentPlayer}l{l.Pos}", true)[0]; // E.g.: p2l0.
// Obtain absolute location of the rack's cell from the top left of the window.
cellAbsLoc = ctl.FindForm().PointToClient(ctl.Parent.PointToScreen(ctl.Location));
lblFlyingLetter.Text = ctl.ToString();
// Change the rack cell back colour to normal.
ctl.BackColor = SystemColors.Control;
ctl.Text = string.Empty;
FlyLetter(cellAbsLoc, FlyingLetterInitialPosOnBag);
LetterBag[l.Pos] = l.Letter;
lblLettersRemaining.Text = $"Letters remaining: {++NUM_LETTERS_IN_BAG}";
Application.DoEvents();
}
字母返回到袋子后,新的字母从袋子中放入空单元格。
foreach (Letters l in ExchangingLetters)
LoadSingleLetter(CurrentPlayer, l.Pos);
最后,交换字母列表(“`ExchangingLetters`”)被清除。
ExchangingLetters.Clear(); // Clear the exchanging letters list.
动画效果
在三种可能的情况下,有两种主要的动画效果。一种是摇晃袋子,另一种是字母在袋子、牌架和棋盘之间飞行。
袋子摇晃只有一种可能的情况。每当要从袋子中抽取一组新字母时,袋子就会摇晃,表示字母将从袋子中抽取。
字母飞行的三种可能场景
- 从袋子飞到牌架。
- 从牌架飞到袋子。
- 从板飞到架子。
摇晃袋子
这非常简单。计时器每 50 毫秒滴答一次,使袋子上下移动。在滴答期间,它会强制线程休眠 10 毫秒 (`THREAD_SLEEP=10`),以使运动可见。如果不进行此休眠,它将以闪电般的速度摇晃;因此玩家将无法感知摇晃效果。
pbLetterBag.Top += SHAKE_PIXELS;
SHAKE_PIXELS = -SHAKE_PIXELS;
Thread.Sleep(THREAD_SLEEP);
Application.DoEvents();
‘`pbLetterBag`’是包含袋子的图像控件。第一次滴答时,它向下移动 20 像素 (`SHAKE_PIXELS=20`)。然后‘`SHAKE_PIXELS`’自身取反;现在它变为 (`-20`)。在下一次滴答时,它向上移动 (`pbLetterBag.Top += SHAKE_PIXELS`)。在下一次滴答时,它变为 (`-(-20) = +20`) (`SHAKE_PIXELS = -SHAKE_PIXELS`),因此它向下移动。事情就这样持续了一段时间。
袋子摇晃一定次数 (`MAX_SHAKE_COUNT=13`) 后,计时器停止,袋子处于静止状态。
字母在袋子、棋盘和牌架之间飞行
字母的飞行是一个简单的动画,它使标签在字母袋、牌架单元格和棋盘单元格之间飞行。它为字母移动创建了一个简单的视觉效果。打个比方,这可以解释为源机场、目的地机场、一架飞机和一名乘客。标签控件用作飞机。需要飞行的字母是飞机的乘客。源是起飞的位置,目的地是降落的位置。
‘`FlyLetter`’方法是飞行逻辑的控制塔。它从调用者接收两个参数——源和目的地。
该方法首先计算从目的地到源的轴差。它在配置文件中有一个预定义的帧计数(可配置)。然后它通过将差异除以帧计数(例如,25 帧)来确定每个方向要飞行的像素数。它四舍五入到小数点后第 4 位(例如:25.2153)。
// Determine axis distance (destination - source).
diff = new Point(dest.X - source.X, dest.Y - source.Y);
// Determine stepping needed by the letter - how many pixels to fly through x and y axes.
SteppingX = Math.Round(diff.X / MAX_ANIMATION_FRAMES, 4);
SteppingY = Math.Round(diff.Y / MAX_ANIMATION_FRAMES, 4);
双精度舍入值的必要性是什么?如果只取整数舍入值会怎样?让我们通过一个例子来澄清这一点。
假设飞行标签(飞机)最初在 (750, 50)。假设步进 X = -10.48,步进 y = 1.52。
第一帧将标签带到:(750 - 10.48, 50 + 1.52) = (739.52, 51.52)。
由于像素必须是整数,因此这被四舍五入为 (740,52)。
然而,我们将上次记录的双精度位置记录在一个全局变量中。我们使用该上次记录的双精度位置来计算下一步移动。
因此,下一个位置是 (739.52 – 10.48, 51.52 + 1.52) = (729.12, 53.04)。
其四舍五入的像素值为 (729, 53)。
现在让我们看看如果使用整数步进(四舍五入)会发生什么。
第一帧是 (740, 12)。
四舍五入后的下一帧是 (740 – 10.48, 52 + 1.52) = (729.52, 53.52) = (730, 54)。
看,这与 (729, 53) 不同,后者计算上是正确的,并且位置更精确。如果使用后者,则方块将以恒定速度移动,并将标签飞出目的地,然后将其丢弃在那里。动画会有一点抖动。打个比方,飞机将飞过跑道,需要第二次尝试才能正确着陆。
一个 `For` 循环促进飞行——它循环最大帧数 (`MAX_ANIMATION_FRAMES=25`),在每一步计算方块的下一个位置,将其飞到那里并等待几秒钟 (`THREAD_SLEEP=10`),以便玩家可以感知飞行。
for (int i = 0; i < MAX_ANIMATION_FRAMES; i++)
{
lastXOfFlyingLabel += SteppingX;
lastYOfFlyingLabel += SteppingY;
// Convert the double-precision to int, as pixel needs to be int.
lblFlyingLetter.Left = (int)lastXOfFlyingLabel;
// Convert the double-precision to int, as pixel needs to be int.
lblFlyingLetter.Top = (int)lastYOfFlyingLabel;
// Unless forced, the effect will not be visible.
Application.DoEvents();
// Allow delay else this will be too fast to see.
Thread.Sleep(THREAD_SLEEP);
}
袋子收缩,即数组收缩
回合结束后(无论成功或失败),数组需要收缩。这是一个面试官经常问的流行面试问题。
让我们思考一下,如果移动后我们让它保持原样会发生什么。数组最终会包含越来越多的 `NULL`(`\0`)字符(记住,每次从袋子中抽取字母时,相应的条目都会被置空,表示字母已被取出)。这使得随机器最终很难找到字母;它会慢慢成为一个瓶颈。
对于一个一百个单元格的数组,这可能无法识别,但如果我们将数组中的单元格视为数百万个,那么随着时间的推移,处理器找到一个非空白单元格将成为瓶颈。
这里使用的逻辑是对数组进行一次扫描;扫描从两端进行——从前面和后面,因此是 O(n)。逻辑如下
- 从数组的开头开始。我们将索引指示器称为“`pos`”。
- 一旦找到一个 `NULL` 单元格(该单元格的字母是在当前回合中抽取的),就标记该位置。我们称之为“`pos`”。
- 现在从末尾开始遍历。
- 一旦找到一个非 `NULL` 单元格,就标记该位置。我们称之为“`lastPickPos`”。
- 将“`lastPickPos`”的字母复制到“`pos`”。
- 将“`lastPickPos`”的字母置空。
- 循环直到“`pos`”超过“`lastPickPos`”。此时,所有 `NULL` 值都被妥协(移到末尾)。
- 现在修剪尾随的 `NULL` 值。这会缩小数组。
private void ShrinkLetterBag()
{
int len = LetterBag.GetUpperBound(0); // Initial length of the bag.
int lastPickPos = len; // Last index is the length of the array.
// Start traversing from the beginning.
for (int pos = 0; pos < lastPickPos; pos++) // Start traversing from the beginning.
{
if (LetterBag[pos] == '\0') // If a NULL is detected
{
while (lastPickPos > pos && LetterBag[lastPickPos] == '\0')
lastPickPos--;
LetterBag[pos] = LetterBag[lastPickPos]; // Copy that last character
// to the beginning NULL index.
LetterBag[lastPickPos] = '\0'; // Nullify the last pick index.
}
}
NUM_LETTERS_IN_BAG = lastPickPos;
Array.Resize(ref LetterBag, NUM_LETTERS_IN_BAG);
}
实际上不需要修剪 (`Array.Resize`),因为随机数生成器只处理上限,它指向数组中最后一个非 `null` 字符的下一个位置。
撤销移动
玩家可以在回合中撤销字母拖动。要撤销,他们只需根据需要多次按下 **CTRL+Z**。这通过以下逻辑实现。
每次从单元格中拖动字母时,该单元格都会触发“鼠标按下”事件。在该事件中,单元格的详细信息(玩家、单元格在牌架上的位置以及单元格中的字母)会添加到类型为“`RackCell`”的变量中。
private string AddSource(Label sender)
{
string letter = sender.Text;
string name = sender.Name;
int player = Convert.ToInt16(name.Substring(1, 1));
int cellInRack = Convert.ToInt16(name.Substring(name.IndexOf("l") + 1));
Source.Add(player, cellInRack, letter);
return letter;
}
当字母被放到棋盘上的单元格上时,该单元格会触发“拖放”事件。此事件将这些详细信息添加到列表(堆栈)中。
Stack<RackCell> RackCellList = new Stack<RackCell>();
……………………………
……………………………
RackCellList.Push(new RackCell(Source));
为什么选择堆栈而不是其他数据结构来完成此操作?你猜对了。**CTRL+Z** 需要以拖放的相反方式工作。当按下 **CTRL+Z** 时,最后一个操作首先被撤销。
通过重写表单的默认按键处理器“`ProcessCmdKey`”来实现 **CTRL+Z**。
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{ …………………………… …………………………… }
**CTRL+Z** 是通过 **CTRL** 键与“**Z**”的位或运算来理解的。
if (keyData == (Keys.Control | Keys.Z))
{ …………………………… …………………………… }
如果是 **CTRL+Z**,则只需执行以下操作:
- 弹出牌架单元格的详细信息。
- 弹出棋盘单元格的详细信息。
- 将牌架单元格的文本设置为拖动之前的值。
- 将棋盘单元格的文本设置为放置之前的值。
- 将矩阵中对应的单元格设置为 `NULL`。
从堆栈中删除条目是通过 `pop()` 操作自动完成的。
一个词的有效性
要成为一个有效的单词,它需要通过以下几点
- 它应该具有有效的方向。
- 它应该具有有效的相邻性。
- 如果是第一个单词,那么它应该穿过中心星形方块。
- 其他情况下,单词应该穿过或接触棋盘上已有的字母。
1) 有效方向
方向的有效性由“`ValidOrientation`”方法确认。
一个单词可以是水平方向或垂直方向。首先,它检查是否是单次放置——玩家只放置了一个字母。在这种情况下,它会尝试通过检查棋盘上现有单词来推断预期方向。这很简单——只需检查当前字母的上方或下方是否有字母——在这种情况下,推断的方向是垂直的。另一方面,如果字母的左侧或右侧有字母,那么这就是水平方向。
int x = boardCells[0].X;
int y = boardCells[0].Y;
int yBelow = y + 1;
int yAbove = y - 1;
int xLeft = x - 1;
int xRight = x + 1;
// If there is a letter above or below the singly-placed letter,
// then the inferred direction is vertical.
if ((yAbove >= 0 && matrix[x, yAbove] != '\0') ||
(yBelow < GRID_SIZE && matrix[x, yBelow] != '\0'))
CurrentWordDirection = WordDirectionEnum.Vertical;
……………………………
……………………………
例如,如果一个字母“`A`”被拖到棋盘上,它发现上方有一个字母“`C`”,因此它推断出预期的方向是垂直的。
如果玩家在棋盘上拖动了多个字母,则它首先通过检查前两个字母来确定方向。
// If this is a vertical drop to the previous drop, then mark the direction as vertical.
if (boardCells[0].X == boardCells[1].X && Math.Abs(boardCells[0].Y - boardCells[1].Y) >= 1)
CurrentWordDirection = WordDirectionEnum.Vertical;
// If this is a horizontal drop to the previous drop, then mark the direction as horizontal.
else if (boardCells[0].Y == boardCells[1].Y &&
Math.Abs(boardCells[0].X - boardCells[1].X) >= 1)
CurrentWordDirection = WordDirectionEnum.Horizontal;
然后它循环遍历其余的字母,并检查其余的字母是否遵循相同的方向。如果所有字母都遵循相同的方向,则通过返回 `true` 来确认。否则,它返回 `false`。
WordDirectionEnum dir = WordDirectionEnum.None;
for (int i = 1; i < boardCells.Count - 1; i++)
{
// Check orientation of each subsequent drops.
if (boardCells[i].X == boardCells[i + 1].X &&
Math.Abs(boardCells[i].Y - boardCells[i + 1].Y) >= 1)
dir = WordDirectionEnum.Vertical;
else if (boardCells[i].Y == boardCells[i + 1].Y &&
Math.Abs(boardCells[i].X - boardCells[i + 1].X) >= 1)
dir = WordDirectionEnum.Horizontal;
// If this orientation is not the same as the first,
// then this must be an abnormal orientation.
if (dir != CurrentWordDirection)
{
MessageBox.Show("Abnormal orientation detected.
Letters must be placed horizontally or vertically. Cannot proceed!",
Properties.Resources.APP_TITLE, MessageBoxButtons.OK, MessageBoxIcon.Information);
CurrentWordDirection = WordDirectionEnum.None;
return false;
}
}
例如,如果玩家在棋盘上放置了“`A`”、“`T`”、“`E`”和“`R`”,那么它首先通过检查前两个字母“`A`”和“`T`”来推断方向为垂直。然后循环继续遍历其余的字母“`E`”和“`R`”,并期望它们保持相同的方向。
如果不是,则这是无效方向。例如
理想情况下,这不会发生,因为玩家不太可能随机玩。但是,无论多么奇怪,一切都应该小心。
2) 有效邻接
为了组成一个有效的单词,字母需要彼此相邻,或者与棋盘上已有的字母相邻。
我们来解释一下水平方向单词的邻接检查。首先,它将字母从左到右排序。
// Clone the list sorted from top to bottom.
List<BoardCell> boardCells = boardCellList.Select(a => a).OrderBy(b => b.X).ToList();
然后它从左到右遍历它们。如果两个连续字母之间有一个以上的单元格间隙,那么它会检查这些间隙单元格中是否存在任何现有字母。
for (int i = 0; i < boardCells.Count - 1; i++)
{
// If the distance between the consecutive letters are more than a cell, that would mean
// the cells in between must contain existing letters.
// Else this would be deemed invalid placement.
if (boardCells[i + 1].X - boardCells[i].X > 1)
// Start traversing from the next cell of the left cell,
// walk towards the left cell of the right cell.
for (int j = boardCells[i].X + 1; j < boardCells[i + 1].X; j++)
// If that cell is empty, means this is an invalid placement.
if (matrix[j, boardCells[i].Y] == '\0')
{
MessageBox.Show("Letters don't have valid horizontal adjacency.
Cannot proceed!", Properties.Resources.APP_TITLE,
MessageBoxButtons.OK, MessageBoxIcon.Information);
return false;
}
}
例如,假设棋盘上已有的单词有 `ACT`、`STAY`、`SOOTH`、`HOE`、`OR`、`PAT`,玩家试图通过它们组成 `SCATTERS`。玩家在棋盘上放置了 `S`、`A`、`T`、`S`。水平方向上,`S` 和 `A` 之间有一个以上的单元格间隙。因此,它们之间必须存在现有字母(即 `C`)。然后 `T` 和 `S` 之间有三个单元格间隙,其中必须有字母(即 `T`、`E`、`R`)。因此,这是一个有效的邻接。
一个无效的邻接可能是以下情况:
3) 第一个单词穿过中心方块
这非常简单。星形方块的位置是 (7, 7)。这只是一个遍历所有已放置字母并查看其中是否有任何一个位置是 (7, 7) 的问题。该逻辑应用于“`FirstWordThroughCentralStarTile`”方法中。
foreach (BoardCell c in boardCellList) // Loop through all the cells.
// If any of them contains (7, 7) - the central tile, then return true.
if (c.X == 7 && c.Y == 7)
return true;
MessageBox.Show("The first word should pass through the central star tile.
Use CTRL+Z to return the letters, then place through the star.",
Properties.Resources.APP_TITLE, MessageBoxButtons.OK,
MessageBoxIcon.Information);
// If none of them contains (7, 7), then return false.
return false;
4) 穿越(或接触)现有单词
检查交叉有两种方法。
- 查看棋盘上单词周围的方块,看看其中是否有任何一个方块是灰色的(`SystemColors.ControlDarkDark`)。如果任何周围的单元格是灰色的,那意味着当前单词穿过(或接触)了一个现有单词。如果当前单词的任何字母的周围方块都不是灰色的,那意味着玩家将单词孤立放置,因此将被视为无效放置(单词应该相互交叉)。
- 查看矩阵的周围单元格,看看其中是否有任何一个是非 `NULL` 的。非 `NULL` 意味着棋盘上有一个现有的有效单词,因此当前单词的放置是有效的(因为它接触或穿过一个现有单词)。否则,这是无效的。
两者都可以实现。在此项目中,选择了第二种。第一种检查 UI 元素颜色,因此需要更多时间。而且,第一种方法是捷径,而第二种方法在通过 2D 字符数组编码方面更具启发性。此外,它更快,因为它使用字符矩阵,而不是任何 UI 元素。
检查在“`CurrentWordCrossedThroughExistingWord`”方法中完成。如果单词穿过或接触棋盘上现有单词,该方法返回 `true`。它接受四个参数:玩家拖到棋盘上的字母列表、字符矩阵、当前玩家和当前回合。最后两个参数是记录移动所需的;它们在检查交叉的逻辑中不起任何作用。
可以有两种方向——水平和垂直。在本部分中,仅解释水平方向;垂直方向可以由此理解。
首先,字母从左到右排序。
// Take a clone of the stack and order from left to right.
letters = letters.Select(a => a).OrderBy(a => a.X).ToList();
然后它检查第一个字母的左侧——如果那里有任何字母,那么这意味着该单词穿过(或接触)了棋盘上已有的单词。它还需要确保此检查不会超出边界(`x >= 0`),因为那会产生运行时错误。
x = letters.First().X - 1; // Check left of the first letter.
y = letters.First().Y;
if (x >= 0) // If not falling out of grid.
if (matrix[x, y] != '\0') // If there is a non-NULL character in the cell.
return true; // Then return true.
例如,如果 `CATER` 已经在棋盘上,并且玩家通过放置 `A` 和 `P` 来构成 `CAP`,那么它会检查 `A` 的左侧是否有字母(为 `true`)。
如果第一个字母的左侧没有字母,则检查最后一个字母的右侧是否有字母。这也需要确保它不会超出边界(`x < GRID_SIZE`)。
x = letters.Last().X + 1; // Check right of the last letter.
y = letters.Last().Y;
if (x < GRID_SIZE) // If not falling out of grid.
if (matrix[x, y] != '\0') // If there is a non-NULL character in the cell.
return true; // Then return true.
例如,如果 `POT` 已经在棋盘上,并且玩家通过放置 `C` 和 `A` 来构成 `CAP`,那么它会检查 `A` 的右侧是否有字母(为 `true`)。
如果以上两种方法都无法确定真相,那么下一步是遍历所有字母,检查上方和下方单元格是否有字母——显然,如果找到字母,则意味着当前单词穿过(或接触)了棋盘上现有单词。同样,这需要确保检查不会超出边界(`y >= 0, y < GRID_SIZE`)。
foreach (BoardCell c in letters)
{
x = c.X;
y = c.Y - 1; // Check top.
if (y >= 0) // If not falling out of grid.
if (matrix[x, y] != '\0') // If there is a non-NULL character in the cell.
return true; // Then return true.
x = c.X;
y = c.Y + 1; // Check bottom.
if (y < GRID_SIZE) // If not falling out of grid.
if (matrix[x, y] != '\0') // If there is a non-NULL character in the cell.
return true; // Then return true.
}
例如,如果棋盘上现有单词有(`PAN`、`NO` 和 `ONE`),并且玩家想要组成单词 `CANOPY`,那么我们手头有 `C`、`A`、`P`、`Y`。我们检查每个字母的顶部和底部单元格,看是否有任何字母。由于这是假的(`C`、`A`、`P`、`Y` 都没有上方或下方字母),所以仍然无法确定它是否接触了任何现有单词。请记住,我们使用的是 `BoardCellList`,其中只有 `C`、`A`、`P` 和 `Y`。`BoardCellList` 对中间的 `N` 和 `O` 一无所知,而这正是我们接下来要发现的。
如果关于交叉的真相仍然无法确定并且我们到达了这一点,那么这意味着预期的单词没有穿过或接触棋盘上任何现有的字母;因此它记录了情况并返回 `false`。
for (x = letters.First().X + 1, y = letters.First().Y; x < letters.Last().X; x++)
// If this is not already in the current player's cell list.
if (letters.FirstOrDefault(a => a.X == x && a.Y == y) == null)
if (matrix[x, y] != '\0') // If there is a non-NULL character in the cell.
return true; // Then return true.
例如,让我们用上一个例子进行检验。我们从 `A` 开始走到 `P`(请记住,玩家放置了 `C`、`A`、`P`、`Y`),看看中间是否有任何字母。首先找到 `A`,但由于 `A` 在当前的 `BoardCellList` 中,所以这不是一个现有字母。我们继续前进并找到了 `N`。由于我们找到了 `N`(即非 `NULL` 字符),我们可以立即宣布我们找到了真相——单词穿过(或接触)了一个现有字母。我们不需要进一步进行,因为任何其他字母都应该已经提前验证过了。
如果真相仍然无法确定并且我们达到了这一点,那么这将意味着预期的单词没有穿过或接触棋盘上的任何现有字母;因此它记录情况并返回 `false`。
MessageBox.Show("The word didn't cross through, or touched another word.
Not a valid placement.", Properties.Resources.APP_TITLE, MessageBoxButtons.OK,
MessageBoxIcon.Information);
Players[currentPlayer].ScoreDetails.Add(new TurnsWithScores
(currentTurn, $"Disjoint word coined.", null));
return false;
垂直单词的检查与此类似,因此不再解释。
评分
评分逻辑有点复杂,用于此目的的数据结构也是如此。主要有两个步骤——加载词典和验证创建的单词。
加载词典
为了评分,显然需要一个字典。为此,使用了此处的一个开源 JSON 字典(其中有 172,819 个单词)。
[
"aa",
"aah",
"aahed",
"aahing",
"aahs",
"aal",
"aalii",
"aaliis",
"aals",
……………………………….
……………………………….
"zymurgy",
"zyzzyva",
"zyzzyvas"
]
需要注意的是,这是一个 `string` 数组。在游戏一开始,字典中的单词被加载到“`Words`”中,它是一个 `JToken` 列表。首先,我们将整个字典获取到一个 `string` 变量“`jsonWords`”中,然后将其反序列化到一个名为“`json`”的 `JArray` 变量中。最后,`JArray` 被转换为 `JToken` 列表。
private static List<JToken> Words;
string jsonWords;
using (StreamReader reader = new StreamReader(WORD_FILE_NAME))
jsonWords = reader.ReadToEnd();
JArray json = (JArray)JsonConvert.DeserializeObject(jsonWords);
Words = json.ToList();
有效主词
首先,我们检查主要创造的单词是否有效。验证委托给“`CheckValidity`”方法。如果它是一个水平单词,那么它首先移动到单词的开头。
while (--x >= 0)
// First walk towards the left until you reach the beginning of the word
// that is already on the board.
if (matrix[x, y] == '\0') break;
// Keep a track of where (x, y) this word started on the board.
startX = ++x;
startY = y;
例如,如果玩家在棋盘上放置了 `A` 和 `P`(有一个现有单词 `CATER`),那么我们从 `A` 开始向左走,直到到达预期单词(`CAP`)的开头。请记住 `BoardCellList` 只有 `A` 和 `P`,它对 `C` 一无所知。
然后我们开始走到单词的末尾。在行走过程中,它还会检查这是否是空白牌,或者是否有奖励内容。此外,它还会记录每个字母的得分详情。“`GetPreimumContent`”方法只是检查当前棋盘单元格内容,看是否有奖励内容(`2W`、`3W`、`2L`、`3L`)。“`CheckIfBlankTile`”方法检查全局空白牌列表,看当前单元格是否有一个空白牌。它逐渐沿着单词走到末尾。因此,它获得了完整的单词。
string premium;
bool blankTile;
// Now walk towards right until you reach the end of the word that is already on the board.
for (int i = 0; x < GRID_SIZE; x++, i++)
{
if (matrix[x, y] == '\0') break;
chars[i] = matrix[x, y];
premium = GetPreimumContent(x, y, boardCellList);
blankTile = CheckIfBlankTile(x, y);
score.Add(new IndividualScore(blankTile ? ' ' : chars[i], premium, new Point(x, y)));
}
string str = new string(chars);
str = str.Trim('\0');
然后它检查这个单词是否在字典中。如果存在,它会进一步检查这个单词是否尚未被任何玩家(包括当前玩家)在相同位置创造过。需要注意的是,相同的单词可以多次创造,但它们不能在相同的位置。
if (Words.IndexOf(str) == -1) // If the word is not found in the dictionary,
return false; // then return negative.
如果单词在字典中找到,那么我们必须检查它是否与之前在同一位置创造的单词相同。如果是,那么这是一个无效的单词。
foreach (PlayerDetails p in Players)
{
foreach (TurnsWithScores t in p.ScoreDetails)
if (t.ValidWords != null)
foreach (ValidWordWithScore v in t.ValidWords)
if (v.Word == str.ToUpper() && v.Axis.X == x && v.Axis.Y == y)
{
existingWord = true;
return false;
}
}
需要注意的是,我们使用了布尔“`out`”变量“`existingWord`”。这个标志决定这个单词是否应该添加到无效单词列表中。如果它是一个棋盘上已有的单词,那么我们不会将它添加到无效单词列表中,因为它只是一个现有单词,但并非无效。
如果它是一个有效的单词,那么它将被添加到有效单词列表中,其中包含每个字母的详细得分以及单词的起始位置。
validWords.Add(new ValidWordWithScore
(str, score, new Point(x, y))); // Add it to the valid list.
有效性确认后,我们需要检查它是否穿过(或接触)了一个现有单词(如果它不是第一个单词)。这已在前面讨论过。
有效次要词
最后,是时候检查是否除了主词之外还创造了次要词。如果除了主词之外还创建了其他单词,那么这些单词也应该有效。
让我们通过一个水平放置的单词“`PASCAL`”来解释。假设棋盘上已有单词——`LAMB` 和 `LA`。玩家在棋盘上拖动了 `P`、`S`、`C`、`A`、`L`。
玩家正确地拼出了主单词 `PASCAL`。然而,看棋盘,他/她又拼出了另一个单词 `AS`,这也需要是一个有效单词(无论如何它都是一个有效单词)。
为此,我们遍历 `P`、`S`、`C`、`A`、`L`(玩家拖动的字母),看看是否通过它们垂直地构成了任何单词。“`P`”上方或下方没有任何字母;所以它被跳过。接下来,我们找到 `AS`;这是一个刚刚诞生的有效单词(不是现有单词)。因此,这个单词(`AS`)被奖励给当前玩家。我们逐渐走到最后一个字母(`C`、`A`、`L`)并检查它们中的每一个。
// Take a clone of the letters that were placed on
// the board in sorted order from left to right.
List<BoardCell> boardCells = boardCellList.Select(a => a).OrderBy(b => b.X).ToList();
int y = boardCells[0].Y;
int yBelow = y + 1;
int yAbove = y - 1;
// Check if there are letters below or above the horizontally placed word.
// The bottom row to check could be less than or equal to the last row (GRID_SIZE).
// The top row to check could be greater than or equal to the first row (0).
//if (y + 1 < GRID_SIZE)
for (int i = 0, x; i < boardCells.Count; i++)
{
x = boardCells[i].X;
if ((yBelow < GRID_SIZE && matrix[x, yBelow] != '\0') ||
(yAbove >= 0 && matrix[x, yAbove] != '\0'))
CheckIfValidWord(x, y, WordDirectionEnum.Vertical, matrix,
validWords, invalidWords, boardCellList);
}
还原
当单词无效时,就会发生还原。如果玩家组成不止一个单词,那么所有这些单词也应该有效。如果至少有一个单词无效,则发生还原——字母被取回牌架,回合移至下一位玩家。
发生几个同步动作。
对于棋盘上绘制的每个字母——
- 获取字母在棋盘上的位置。
- 获取字母在拖动之前所处的牌架上的位置。
rackCell = RackCellList.Pop(); boardCell = BoardCellList.Pop(); rackCellCtl = Controls.Find($"p{rackCell.Player}l{rackCell.Cell}", true)[0]; // E.g.: p2l4 rackCellAbsLoc = rackCellCtl.FindForm().PointToClient (rackCellCtl.Parent.PointToScreen(rackCellCtl.Location)); boardCellCtl = Controls.Find($"l{boardCell.X}_{boardCell.Y}", true)[0]; // E.g.: l4_12 boardCellAbsLoc = boardCellCtl.FindForm().PointToClient (boardCellCtl.Parent.PointToScreen(boardCellCtl.Location));
- 如果任何字母是空白牌,那么
- 从全局列表“`BlankTileRackLocations`”中删除相应的条目。
- 从“`BlankTiles`”列表中删除相应的条目。
- 将飞行字母文本设置为空白字母(比喻地说,检查乘客,然后登机)。
- 将牌架单元格背景颜色设置回空白牌颜色(兰花色)。
- 否则,将飞行字母文本设置为棋盘单元格上的字母。
if (BlankTiles.Count > 0) { RackCell c = BlankTiles.FirstOrDefault(a => a.Player == rackCell.Player && a.Cell == rackCell.Cell); if (c != null) { // Remove the entry from the global 'BlankTilesLocation' if the location matches. BlankTileRackLocations.Remove(BlankTileRackLocations.FirstOrDefault (a => a.X == boardCell.X && a.Y == boardCell.Y)); lblFlyingLetter.Text = " "; BlankTiles.Remove(c); rackCellCtl.BackColor = BACK_COLOR_FOR_BLANK_TILE; } } else lblFlyingLetter.Text = boardCellCtl.Text;
- 如果它是中央星形单元格,则将棋盘单元格的字体设置为更大的(48)。
if (boardCell.X == 7 && boardCell.Y == 7) boardCellCtl.Font = STAR_CELL_FONT;
- 如果高级标记已开启,则在棋盘单元格上显示标记(2L、3L、2W、3W)(如果它是高级单元格)。
- 否则只在单元格上显示高级颜色(如果它是高级单元格)。
// If premPut back any special content like 2L, 3W etc. if (PremimumIdentifierToggle) boardCellCtl.Text = boardCell.PremiumContent.ToString(); else boardCellCtl.Text = ""; // Premium colour only.
- 如果此牌架单元格是空白牌,则将棋盘单元格的背景颜色更改回拖动空白牌之前的值。
BlankTileOnBoard blankTile = BoardBlankTiles.FirstOrDefault (a => a.Cell.X == boardCell.X && a.Cell.Y == boardCell.Y); if (blankTile != null) { boardCellCtl.BackColor = blankTile.Colour; BoardBlankTiles.Remove(blankTile); }
- 将字母飞回牌架。
- 将矩阵中对应的单元格设为 `NULL`(“`\0`”)。
FlyLetter(boardCellAbsLoc, rackCellAbsLoc); rackCellCtl.Text = lblFlyingLetter.Text; // Clear the corresponding cell in the matrix to enable next drag-drop on it. Matrix[boardCell.X, boardCell.Y] = '\0';
分数追踪
每个玩家的得分详情在每个回合都会被记录——无论单词拼写是否正确,是否为跳过,或者是否拼写了无效单词。这就是所谓的“可追溯性”——我们的工作在生活的各个方面都应该是可追溯和诚实的。
要了解得分轨迹,让我们看看下面的 DGML 图。
“`PlayerDetails`”类有一个“`TurnsWithScores`”对象,该对象又有一个“`DetailedScore`”对象。
在每次事件(有效措辞、无效措辞、通过)中,都会记录回合与有效词(简单字符串)、当前回合数以及该回合的详细分数(如果措辞有效)。如果当前措辞中存在无效词,则详细分数为空。
详细分数包含当前单词的轴信息、总分以及在此回合中实际形成的有效单词。需要注意的是,“`TurnsWithScores`”是一个“`DetailedScore`”列表;当前回合中可能有很多有效单词,每个单词都将记录自己的历史。
这只是将正确的数据放在正确的位置。
如果出现失败,那很容易——只需将回合数、无效单词和有效单词添加到当前玩家的得分详情中。
Players[currentPlayer].ScoreDetails.Add(new TurnsWithScores(currentTurn, $"Invalid word(s):
{string.Join(", ", invalidWords)}.{Environment.NewLine}{Environment.NewLine}", validWords));
如果成功,那也很容易——只需添加到当前玩家的得分详情中
- 回合数
- 每个有效单词的得分详情
- 以及每个有效词的单个字母分数
Players[currentPlayer].ScoreDetails.Add
(new TurnsWithScores(currentTurn, str.ToString(), validWords));
以下是对象快照。
玩家[0] 姓名:Mehedi,总分存储在“`TotalScore`”中。
该玩家目前有两个单词。
在回合 1 中,有效单词的“`DetailedScore`”保留了显示的精确消息。
得分明细在“`Score`”对象中可见。拼成的有效单词记录在“`Word`”中。分数的明细记录在“`Score`”对象中。它保留了字母的轴、任何奖励内容、字母得分和字母本身。空白牌是一个空格,没有分数价值。
游戏结束
游戏可能以三种方式结束
- 游戏达到最大连续失败次数 (6)。
- 袋子里没有更多字母,当前玩家的牌架是空的。
- 袋子里没有更多字母,所有玩家的牌架中都有剩余字母。
达到最大失败次数
如果连续有 6 次失败(弃权、交换或失败的单词),则游戏结束。如果游戏以这种方式结束,则从每个玩家总分中扣除其牌架中剩余字母的面值。这是一个简单的逻辑——遍历玩家的所有字母,计算面值总和,从分数中扣除。这在“`EndGame()`”方法中完成。
扣除后,得分最高者赢得游戏。
袋子里没有更多字母,牌架是空的
这种情况发生在袋子空了,并且当前玩家的牌架中没有更多字母时(他/她成功用完了所有字母)。在这种情况下,会发生两件事。
- 其他玩家牌架中所有字母的面值都会加到当前玩家的分数中。
- 对于剩余的玩家,他们每个人的分数都会减少,减少的金额是他们自己牌架中剩余字母的分数总和。
经过这个简单的算术,得分最高者获胜。
袋子里没有更多字母,牌架不空
这种情况发生在袋子空了,并且当前玩家的牌架中(以及其他玩家的牌架中)仍有剩余字母。需要注意的是,在这种情况下,其他玩家将有 7 个字母剩余,而当前玩家可能少于或等于 7 个字母。这是因为在补充当前玩家的牌架时,袋子可能会变空。
例如,假设当前玩家在当前回合中放置了 5 个字母(牌架中剩下 2 个),然后选择补充;假设袋子里只有 3 个字母,因此当前玩家补充后将有 (2+3) = 5 个字母。因此触发条件——袋子空了,所有玩家的牌架中都有剩余字母。
在这种情况下,达到最大失败次数的逻辑同样适用——所有玩家的分数都将通过他们自己剩余字母分数的总和来减少。
故障
如果您发现任何问题,请在评论中指出,我将不胜感激。我将在以后的版本中修复。
限制
两种动画效果(摇晃袋子和字母飞行)一个接一个地发生。然而,这涉及到大量的线程休眠和强制进程完成。有时根据处理器速度,这两种效果可能无法正确同步。
未来工作
这个项目还有很多工作可以做。
- 引入限时挑战。
- 与电脑对战。这将涉及字谜算法和复杂的 AI 方法。
- 既然棋盘上有这么多舞蹈,为什么不加点音乐呢?:)
- 提供更好的机制来正确同步两种动画效果。
- 添加限时挑战。
- 将游戏扩展到不同的 Unicode 语言。为不同的 Unicode 语言创建规则和分数。
- 连接 Google 词典网络服务(我不知道是否有;尚未对此进行研究——尽管有一些其他付费服务)。API 可以被调用以在组成单词时获取其含义。例如,我个人不知道有一个有效单词“`AA`”,其含义是“形成非常粗糙、锯齿状块状、质地轻盈松软的玄武岩熔岩”(——来自 Google)。我只是在游戏中尝试了一下,它是正确的(这只是侥幸)。所以,最好知道其含义(即使有些尝试可能纯属侥幸)。
- 可能存在逻辑重叠——我相信有些逻辑或数据结构是为了不同的目的而重复的。这些可以通过使其通用化来识别和解决。
- 这并非完全面向对象。重点更多放在构建逻辑上,因此它更偏向过程式。代码的不同部分可以更精简,并与优雅和更新的 OOP 概念和设计模式保持一致。
- 不使用鼠标拖放,而是使用键盘从牌架复制字母,然后简单地用鼠标单击要放置的单元格。这可能会节省时间吗?
- 通过撤销(**CTRL+Z**)将字母飞回牌架留作读者练习。让我们试一试。
- 初始吉祥物选择中的吉祥物拖放操作是一次性操作;没有撤消选项。这留给读者练习,以应用(**CTRL+Z**)取回吉祥物并选择另一个。
- 如果多名玩家抽到与“`A`”最接近的相同字母,则应重新抽牌,而不是将起始权授予在顺时针方向上遇到的第一个具有最接近字母的玩家。
理由总结
何时缩小袋子
问题可能出现,为什么袋子是在当前回合结束或弃权时才收缩?为什么不是在抽到字母后立即收缩?原因在于交换。交换会将选定的字母返回到袋子中它们被抽出的相同索引位置。因此,如果袋子在抽出后立即收缩,那么返回的字母就没有空间移入。那将是一场混乱,世界末日(游戏结束)。
堆栈、队列、列表的使用
这些已在相关章节中解释。只使用了一个队列;否则可以使用列表。此外,现有的列表或堆栈也可以借用于队列“`DrawnLetters`”;这个只用于确定第一个玩家。
使用四个绿色按钮
这些也可以只用一个按钮来完成,该按钮会根据回合移动。当它被点击时,可以显示相应的分数详情。这只是留给读者的一项简单练习。
关注点
这个项目使用计时器。计时器是一个与线程无关的组件。这意味着它可以独立运行,而其他活动可以同时进行。然而,动画需要相反的效果;其他线程需要停止/暂停,直到计时器完成。例如,字母不应该在摇晃袋子的计时器完成摇晃袋子之前飞行(即,直到该计时器停止)。
另一个有趣的方面是控件的绝对位置。牌架单元格和棋盘单元格位于面板内部。如果直接使用它们的轴,那么它是相对于容器面板测量的,而不是相对于窗口测量的。这就是为什么需要一个绝对位置。例如
// Obtain absolute location of the rack's cell from the top left of the window.
cellAbsLoc = ctl.FindForm().PointToClient(ctl.Parent.PointToScreen(ctl.Location));
致谢
感谢各网站和组织使用其图片作为吉祥物,包括 Ubisoft(用于波斯王子和超越善恶吉祥物)、微软(用于刺客信条吉祥物)、Pyro 和 Eidos(用于绿色贝雷帽吉祥物)、环球影业(用于格鲁和艾格尼丝吉祥物)、任天堂(用于马里奥吉祥物)、华特迪士尼(用于闪电狗、飞屋环游记和疯狂动物城吉祥物)、环球影业(用于皮博迪先生和谢尔曼吉祥物)、20 世纪福克斯(用于尤达吉祥物)和环球影城(用于老虎布吉祥物)。
这些吉祥物是我生活中随机挑选的一些瞬间。
免责声明
该项目以“warbler”(莺)命名,这是一种可爱的小鸟,发出可爱的小鸣叫——鼓励玩家发出(可爱)的小词。它(标题)也与填字先生相得益彰;它们各自有其优点。现在不再有性别偏见。我们是平等的 :)
该项目使用了一些图像和一个 GIF。这使得项目和可执行文件的大小略有增加。对于下载项目和可执行文件所需的带宽略高,我深表歉意。我不想为了单次下载高带宽的需求而牺牲华丽的修饰。
我无法测试游戏结束的边缘情况。如果有人玩到最后并发现任何故障,我将不胜感激,如果您能在评论中提及。
该项目纯粹用于教育目的,旨在实验逻辑开发并鼓励为实际用途思考数据结构和算法。它还致力于逻辑开发,以在字符串中查找结构模式。各种方面,如措辞、评分、棋盘、弃权、规则,都借鉴自各种单词拼写游戏(例如,Scrabble、Dabble、Typo、Quiddler、Qwirkle 等)和填字游戏规则。
有趣的坑
当游戏开始时,如果你连续犯了六次失败(只需按 6 次“跳过”),那么仍然会根据常规的游戏结束规则宣布获胜者。没有任何规则说,棋盘上没有放置任何一个单词就意味着没有人能赢。尽管这很有趣,但这与人生深层次的哲学洞察力有关——世界上有些人无需动一根汗毛就能得分(或赚钱)(开玩笑,别当真)。
参考文献
- 开源词典
- 控件的绝对位置
- Ctrl 键与鼠标点击的组合
- 在线图像编辑器
- ASCII 图表
- 一个完整的 C# .NET 单词拼图游戏
- Winforms 的响应式设计技术
- 袋子图片
- 鞭炮图片
- 发明家图像
- 填字先生
- 艾格尼丝的画像
- 博尔特的图片
- 格鲁的画像
- 波斯王子图片
- 马里奥人图像
- 卡尔·弗雷德里克森图片
- 刺客信条图片
- 汤姆和杰瑞图片
- 谢尔曼的画像
- 尤达图像
- 疯狂动物城图片
- 虎子 Boo 图片
- 翡翠的图片
- 拉塞尔的图片
- 绿扁帽的图片
- 鞭炮图片
- 绿色按钮
- 剪刀图标
历史
- 2019年4月11日:首次发布