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

C#.NET 中的完整单词益智游戏

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (25投票s)

2016年10月10日

CPOL

18分钟阅读

viewsIcon

121045

downloadIcon

3838

一个烧脑的眼部谜题,看看你是否能战胜它!

背景

我很久以前用 Turbo C 编写了这个游戏。但我丢失了代码。我想用 C#.NET 重新激活它。该语言在内存、垃圾回收、图形方面提供了很多灵活性,而我使用 C 时不得不明确处理这些问题。但通过在 C 中进行明确的考虑,它提供了很多学习机会(这就是为什么它被称为“上帝的编程语言”)。另一方面,由于 C# .NET 会处理这些问题,我可以专注于其他增强功能,如单词方向、重叠、秘籍、计分、加密等。所以我们需要欣赏这两种语言的平衡。

我称其为完整,原因如下:

  1. 它有预设单词及一些类别。
  2. 它将单词和分数保存在加密文件中,这样没有人可以篡改文件。如果发生篡改,它将恢复到预设,并从头开始计分。
  3. 它有两个秘籍,但作弊会通过从当前分数中减去 50 来惩罚得分。
  4. 它有一个计分机制。
  5. 它有一个得分摘要,以便玩家可以检查计分机制。

Using the Code

游戏提供了以下将在后续章节中讨论的功能:

  1. 加载类别和单词:单词从程序中硬编码的预设值加载。但是,如果玩家提供自定义单词,则游戏会自动将所有这些单词(以及预设值)存储在一个文件中并从中读取。
  2. 选择方向:在 v3.0 版本中,游戏是全方位的。这意味着单词可以放置在 8 个可能方向中的任何一个。
  3. 在网格上放置:游戏以随机位置和随机方向将所有单词放置在 18x18 的矩阵中。正如您在上图所示,有 8 个可能的方向:右、下、左下、右下、左、上、左上和右上。
  4. 计分:分数单独为每个类别存储。分数计算为单词长度乘以一个因子。根据不同的难度级别,乘法因子设置为不同的值,如下所示。这些在此称为“增强器”。增强器根据难度级别选择。例如,左方向的乘数为 20,而右方向的乘数为 10,因为找到左方向的单词比找到右方向的单词更难。
    此外,在找到所有单词后,剩余时间乘以乘法因子(在此版本中为 10)将添加到分数中。
  5. 显示隐藏单词:如果时间用完,而玩家未能找到所有单词,游戏将以不同的颜色显示这些单词。当应用“FLASH”秘籍时,也会使用相同的方法来闪烁单词。
  6. 摘要显示:游戏结束时,会显示摘要以及游戏棋盘的快照,以便为玩家提供得分详情。
  7. 秘籍:游戏在游戏面板上提供了两个秘籍(mambazamba,flash)。第一个秘籍会将时间增加 100 秒。第二个秘籍会闪烁单词一秒钟,然后再次隐藏它们。两个秘籍都会通过从当前分数中扣除 50 分来惩罚分数。

1) 加载类别和单词

加载预设

为了保存类别和单词,有一个类 `WordEntity`。

class WordEntity
{
    public string Category { get; set; }
    public string Word { get; set; }
}

这里有一些预设类别和单词。预设值全部由管道分隔,每第 15 个单词是类别名称,后面的单词是该类别中的单词。

private string PRESET_WORDS =
"COUNTRIES|BANGLADESH|GAMBIA|AUSTRALIA|ENGLAND|NEPAL|INDIA|PAKISTAN|
 TANZANIA|SRILANKA|CHINA|CANADA|JAPAN|BRAZIL|ARGENTINA|" +
"MUSIC|PINKFLOYD|METALLICA|IRONMAIDEN|NOVA|ARTCELL|FEEDBACK|ORTHOHIN|
 DEFLEPPARD|BEATLES|ADAMS|JACKSON|PARTON|HOUSTON|SHAKIRA|" +
...

使用加密来写入这些单词到文件中,以便没有人可以篡改文件。如果发现任何篡改,游戏将从内置类别重新加载。对于加密,一个类是从 这里 借用的。它很简单——只需将 `string` 和加密密码传递给方法。对于解密,需要传递加密的 `string` 和密码。

如果单词文件存在,则从那里读取类别和单词,否则将保存预设值(以及玩家的自定义单词)并从中读取。这在以下代码中完成:

if (File.Exists(FILE_NAME_FOR_STORING_WORDS))   // If words file exists, then read it.
    ReadFromFile();
else
{   // Otherwise, create the file and populate from there.
    string EncryptedWords = StringCipher.Encrypt(PRESET_WORDS, ENCRYPTION_PASSWORD);
    using (StreamWriter OutputFile = new StreamWriter(FILE_NAME_FOR_STORING_WORDS))
        OutputFile.Write(EncryptedWords);
    ReadFromFile();
}

`ReadFromFile()` 方法只是从存储单词的文件中读取。它首先尝试解密从文件中读取的字符串。如果失败(由返回的空字符串确定),则显示有关问题的消息,然后从内置预设中重新加载。否则,它会遍历字符串并将它们分成类别和单词,并将它们放入单词列表中。每第 15 个单词是类别,接下来的单词是该类别下的单词。

string Str = File.ReadAllText(FILE_NAME_FOR_STORING_WORDS);
string[] DecryptedWords = StringCipher.Decrypt(Str, ENCRYPTION_PASSWORD).Split('|');
if (DecryptedWords[0].Equals(""))  // This means the file was tampered.
{
    MessageBox.Show("The words file was tampered. 
                     Any Categories/Words saved by the player will be lost.");
    File.Delete(FILE_NAME_FOR_STORING_WORDS);
    PopulateCategoriesAndWords();   // Circular reference.
    return;
}

string Category = "";

for (int i = 0; i <= DecryptedWords.GetUpperBound(0); i++)
{
    if (i % (MAX_WORDS + 1) == 0)   // Every 15th word is the category name.
    {
        Category = DecryptedWords[i];
        Categories.Add(Category);
    }
    else
    {
        WordEntity Word = new WordEntity();
        Word.Category = Category;
        Word.Word = DecryptedWords[i];
        WordsList.Add(Word);
    }
}

保存玩家自定义单词

游戏提供使用玩家提供的自定义单词进行游戏的功能。该功能在同一个加载窗口上可用。单词长度应最短为 3 个字符,最长为 10 个字符,并且必须正好有 14 个单词 - 不多不少。这会在标签中说明。此外,一个单词不能是任何其他单词的子部分。例如:不能有两个单词,如“`JAPAN`”和“`JAPANESE`”,因为前者包含在后者中。

单词有一些有效性检查。在最大长度和空格输入(不允许空格)方面有两次即时检查。这是通过将自定义处理程序 `Control_KeyPress` 添加到单词输入网格的 `EditingControlShowing` 事件来完成的。

private void WordsDataGridView_EditingControlShowing
(object sender, DataGridViewEditingControlShowingEventArgs e)
{    
    e.Control.KeyPress -= new KeyPressEventHandler(Control_KeyPress);
    e.Control.KeyPress += new KeyPressEventHandler(Control_KeyPress);
}

每当用户输入内容时,都会调用处理程序并检查有效性。这是这样做的:

TextBox tb = sender as TextBox;
if (tb.Text.Length >= MAX_LENGTH)   // Checking max length
{
    MessageBox.Show("Word length cannot be more than " + MAX_LENGTH + ".");
    e.Handled = true;
    return;
}
if (e.KeyChar.Equals(' '))          // Checking space; no space allowed. 
                                    // Other invalid characters check can be put here 
                                    // instead of the final check on save button click.
{
    MessageBox.Show("No space, please.");
    e.Handled = true;
    return;
}
e.KeyChar = char.ToUpper(e.KeyChar);

当所有单词都输入完毕,并且用户选择保存并使用自定义单词进行游戏后,会进行另一次有效性检查。首先,它检查是否输入了 14 个单词。然后,它遍历所有 14 个单词,检查它们是否包含无效字符。同时,它还会检查重复的单词。检查成功后,将单词添加到列表中。

然后它遍历列表并检查是否有任何单词的长度小于 3。如果遇到任何此类单词,则会弹出消息。

最后,它会与列表中的单词进行另一次迭代,以检查一个单词是否包含在另一个单词中(例如,不能有两个单词,如“`JAPAN`”和“`JAPANESE`”,因为前者包含在后者中)。这是在 `CheckUserInputValidity()` 方法中完成的,如下所示:

if (WordsDataGridView.Rows.Count != MAX_WORDS)
{
    MessageBox.Show("You need to have " + MAX_WORDS + " words in the list. Please add more.");
    return false;
}

char[] NoLettersList = { ':', ';', '@', '\'', '"', '{', '}', 
                        '[', ']', '|', '\\', '<', '>', '?', ',', '.', '/',
                        '`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 
                        '-', '=', '~', '!', '#', '$',
                        '%', '^', '&', '*', '(', ')', '_', '+'};   //'
foreach (DataGridViewRow Itm in WordsDataGridView.Rows)
{
    if (Itm.Cells[0].Value == null) continue;
    if (Itm.Cells[0].Value.ToString().IndexOfAny(NoLettersList) >= 0)
    {
        MessageBox.Show("Should only contain letters. 
                         The word that contains something else other than letters is: 
                         '" + Itm.Cells[0].Value.ToString() + "'");
        return false;
    }
    if (WordsByThePlayer.IndexOf(Itm.Cells[0].Value.ToString()) != -1)
    {
        MessageBox.Show("Can't have duplicate word in the list. 
                         The duplicate word is: '" + Itm.Cells[0].Value.ToString() + "'");
        return false;
    }
    WordsByThePlayer.Add(Itm.Cells[0].Value.ToString());
}

for (int i = 0; i < WordsByThePlayer.Count - 1; i++) // For every word in the list 
                                                     // check the minimum length; 
                                                     // it should be at least 
                                                     // 3 characters long.
    if (WordsByThePlayer[i].Length <3)
        {
            MessageBox.Show("Words must be at least 3 characters long. 
                             A word '" + WordsByThePlayer[i]  + 
                             "' is encountered having less than the acceptable length.'");
            return false;
        }

for (int i = 0; i < WordsByThePlayer.Count - 1; i++)     // For every word in the list.
{
    string str = WordsByThePlayer[i];
    for (int j = i + 1; j < WordsByThePlayer.Count; j++) // Check existence with every other
                                                         // word starting from the next word
        if (str.IndexOf(WordsByThePlayer[j]) != -1)
        {
            MessageBox.Show("Can't have a word as a sub-part of another word. 
                             Such words are: '" + WordsByThePlayer[i] + "' and 
                             '" + WordsByThePlayer[j] + "'");
            return false;
        }
}
return true;

玩家的列表会与现有单词一起保存,然后游戏棋盘将用该类别的单词打开。

2) 选择方向

游戏是全方位的;这意味着它提供了在任何方向上放置单词的灵活性。它至少需要 2 个方向。选择的方向将施加一个计分增强器,这实际上是一个乘法因子。此因子根据难度选择。例如,右上和左上方向似乎是最难的,因此它们具有 30 的增强器,而右方向(增强器为 10)等较容易的方向。选择方向后,选择项将被传递给游戏引擎,该引擎会处理这些方向。

private void PlayButton_Click(object sender, EventArgs e)
{
    try
    {
        List<GameEngine.Direction> ChosenDirections = new List<GameEngine.Direction>();
        if (!ListedDirectionsSuccessfully(ref ChosenDirections))
        {
            MessageBox.Show("Please choose at least two directions.");
            return;
        }

        GameBoard Board = new GameBoard(CurrentWords, CurrentCategory, ChosenDirections);
        Board.MdiParent = Parent.FindForm();
        Board.Show();
        Close();
    }
    catch (Exception Ex)
    {
        MessageBox.Show("An error occurred in 'PlayButton_Click' 
        method of 'ChooseDirections' form. Error msg: " + Ex.Message);
    }
}

private bool ListedDirectionsSuccessfully(ref List<GameEngine.Direction> Directions)
{
    foreach (Control Ctl in Controls)
        if (Ctl is CheckBox)
            if ((Ctl as CheckBox).Checked)
                Directions.Add((GameEngine.Direction)Enum.Parse
                (typeof(GameEngine.Direction), Ctl.Tag.ToString()));
    return Directions.Count >= 2;
}

需要注意的是,`GameEngine.Direction` 是从游戏引擎类借用的,该类是 `Direction enum` 的实际宿主。

3) 在网格上放置

主要代码和逻辑在 `GameEngine` 类中。

在网格上放置单词

单词放置在 `InitializeBoard()` 方法的网格中。有一个字符矩阵(二维字符数组) `WORDS_IN_BOARD`,单词首先放置在其中。然后将此矩阵映射到网格。所有单词逐个迭代。对于每个单词,会获得一个随机位置以及随机方向(从 8 个方向中选择)。此时,单词矩阵看起来大致如下:

`PlaceTheWords()` 方法完成放置,该方法获取四个参数 - 单词的方向、X 和 Y 坐标以及单词本身。这是一个关键方法,因此需要对所有 8 个方向进行清晰的解释。

向右方向

逐个字符地遍历整个单词。首先,它检查单词是否超出网格。如果为 `true`,则返回调用过程,要求生成新的随机位置和方向。

如果通过了上面的边界检查,它会检查当前字符是否可能与网格上已有的字符重叠。如果发生这种情况,它会检查字符是否相同。如果字符不相同,则返回调用方法,要求另一个随机位置和方向。

在完成这两个检查后,如果可以放置,则将单词放置在矩阵中,并通过 `StoreWordPosition()` 方法将位置和方向存储在 `WordPositions` 列表中。

case Direction.Right:
    for (int i = 0, j = PlacementIndex_X; i < Word.Length; i++, j++) // First we check 
                                                                     // if the word can be 
                                                                     // placed in the array. 
                                                                     // For this it needs 
                                                                     // blanks there.
    {
        if (j >= GridSize) return false; // Falling outside the grid. 
                                         // Hence placement unavailable.
        if (WORDS_IN_BOARD[j, PlacementIndex_Y] != '\0')
            if (WORDS_IN_BOARD[j, PlacementIndex_Y] != Word[i])   // If there is an overlap, 
                                                                  // then we see if the 
                                                                  // characters match. 
                                                                  // If matches, then it 
                                                                  // can still go there.
            {
                PlaceAvailable = false;
                break;
            }
    }
    if (PlaceAvailable)
    {   // If all the cells are blank, or a non-conflicting overlap is available, 
        // then this word can be placed there. So place it.
        for (int i = 0, j = PlacementIndex_X; i < Word.Length; i++, j++)
            WORDS_IN_BOARD[j, PlacementIndex_Y] = Word[i];
        StoreWordPosition(Word, PlacementIndex_X, PlacementIndex_Y, OrientationDecision);
        return true;
    }
    break;
单词位置

`WordPosition` 类在保存单词映射、像素信息、方向和计分增强器方面起着至关重要的作用。该类如下:

public class WordPosition
{
    public string Word { get; set; }
    public int PlacementIndex_X { get; set; }
    public int PlacementIndex_Y { get; set; }
    public GameEngine.Direction Direction { get; set; }
    public int ScoreAugmenter { get; set; }
}

并且保存单词位置的方法如下。它获取四个参数——单词本身、单词的 X 和 Y 坐标以及方向。它实例化上述类,存储信息,并根据方向设置增强因子。

private void StoreWordPosition(string Word, int PlacementIndex_X, 
                               int PlacementIndex_Y, Direction OrientationDecision)
{
    WordPosition Pos = new WordPosition();
    Pos.Word = Word;
    Pos.PlacementIndex_X = PlacementIndex_X;
    Pos.PlacementIndex_Y = PlacementIndex_Y;
    Pos.Direction = OrientationDecision;

    switch (OrientationDecision)
    {
        case Direction.Down: Pos.ScoreAugmenter = 10; break;
        case Direction.Up: Pos.ScoreAugmenter = 20; break;
        case Direction.Right: Pos.ScoreAugmenter = 10; break;
        case Direction.Left: Pos.ScoreAugmenter = 20; break;
        case Direction.DownLeft: Pos.ScoreAugmenter = 20; break;
        case Direction.DownRight: Pos.ScoreAugmenter = 20; break;
        case Direction.UpLeft: Pos.ScoreAugmenter = 30; break;
        case Direction.UpRight: Pos.ScoreAugmenter = 30; break;
        case Direction.None: Pos.ScoreAugmenter = 0; break;
    }
    WordPositions.Add(Pos);
}
其他方向

查找这 7 个其他方向单词的放置位置的逻辑相同。它们在矩阵位置的增量/减量和边界检查方面有所不同。

在所有单词都放置在矩阵中后,`FillInTheGaps()` 方法用随机字母填充剩余的矩阵。对于每个 NULL 单元格(\0),它会生成一个随机大写字母并放入其中。

for (int i = 0; i < GridSize; i++)
    for (int j = 0; j < GridSize; j++)
        if (WORDS_IN_BOARD[i, j] == '\0')
            WORDS_IN_BOARD[i, j] = (char)(65 + GetRandomNumber(Rnd, 26));

此时,表单打开并触发 `Paint()` 事件。在此事件中,我们首先绘制线条,这些线条最终显示为 40x40 像素的矩形。然后我们将字符矩阵映射到棋盘。

Pen pen = new Pen(Color.FromArgb(255, 0, 0, 0));

ColourCells(ColouredRectangles, Color.LightBlue);
if (FailedRectangles.Count > 0) ColourCells(FailedRectangles, Color.ForestGreen);

// Draw horizontal lines.
for (int i = 0; i <= GridSize; i++)
    e.Graphics.DrawLine(pen, 40, (i + 1) * 40, GridSize * 40 + 40, (i + 1) * 40);

// Draw vertical lines.
for (int i = 0; i <= GridSize; i++)
    e.Graphics.DrawLine(pen, (i + 1) * 40, 40, (i + 1) * 40, GridSize * 40 + 40);

MapArrayToGameBoard();

`MapArrayToGameBoard()` 方法只是将字符矩阵放置在棋盘上。此处使用了来自 MSDN 的绘图代码。它遍历矩阵中的所有字符,将它们放置在 40x40 像素矩形的中间,边距校准为 10 像素。

Graphics formGraphics = CreateGraphics();
Font drawFont = new Font("Arial", ResponsiveObj.GetMetrics(16));
SolidBrush drawBrush = new SolidBrush(Color.Black);
string CharacterToMap;

try
{
    for (int i = 0; i < GridSize; i++)
        for (int j = 0; j < GridSize; j++)
        {
            if (TheGameEngine.WORDS_IN_BOARD[i, j] != '\0')
            {
                CharacterToMap = "" + TheGameEngine.WORDS_IN_BOARD[i, j]; // "" is needed 
                                                                          // as a means for 
                                                                          // conversion of 
                                                                          // character to 
                                                                          // string.
                formGraphics.DrawString(CharacterToMap, drawFont, drawBrush, 
                                       (i + 1) * SizeFactor + CalibrationFactor, 
                                       (j + 1) * SizeFactor + CalibrationFactor);
            }
        }
}

单词查找和有效性检查

鼠标单击和释放的位置存储在 `Points` 列表中。`CheckValidityAndUpdateScore()` 方法在鼠标按钮释放事件(`GameBoard_MouseUp()`)上调用。与此同时,当用户在按下左键拖动鼠标时,会从起始位置到鼠标指针绘制一条线。这在 `GameBoard_MouseMove()` 事件中完成。

if (Points.Count > 1)
    Points.Pop();
if (Points.Count > 0)
    Points.Push(e.Location);

// Form top = X = Distance from top, left = Y = Distance from left.
// However mouse location X = Distance from left, Y = Distance from top.

// Need an adjustment to exact the location.
Point TopLeft = new Point(Top, Left);
Point DrawFrom = new Point(TopLeft.Y + Points.ToArray()[0].X + CalibrationFactor, 
                 TopLeft.X + Points.ToArray()[0].Y + MouseDrawYCalibrationFactor);
Point DrawTo = new Point(TopLeft.Y + Points.ToArray()[1].X + CalibrationFactor, 
               TopLeft.X + Points.ToArray()[1].Y + MouseDrawYCalibrationFactor);

ControlPaint.DrawReversibleLine(DrawFrom, DrawTo, Color.Black); // draw new line

单词的有效性在 `CheckValidity()` 方法中检查。它通过查看相应的字符矩阵来组合使用鼠标绘制的所有字母来构成单词。然后它会检查这是否真的匹配我们单词列表中的单词。如果匹配,则通过将单元格染成浅蓝色并使单词在单词列表中变灰来更新单元格。

以下代码片段获取线条的开始和结束位置。首先,它检查线条是否超出边界。然后它构成单词并存储矩形的坐标。类似地,它检查垂直、左下和右下单词,并尝试相应地进行匹配。如果确实匹配,则通过 `AddCoordinates()` 方法将临时矩形存储在我们的 `ColouredRectangles` 点列表中。

if (Points.Count == 1) return; // This was a doble click, no dragging, hence return.
int StartX = Points.ToArray()[1].X / SizeFactor;    // Retrieve the starting position 
                                                    // of the line.
int StartY = Points.ToArray()[1].Y / SizeFactor;

int EndX = Points.ToArray()[0].X / SizeFactor;      // Retrieve the ending position 
                                                    // of the line.
int EndY = Points.ToArray()[0].Y / SizeFactor;

if (StartX > GridSize || EndX > GridSize || StartY > GridSize || 
                                            EndY > GridSize || // Boundary checks.
    StartX <= 0 || EndX <= 0 || StartY <= 0 || EndY <= 0)
    StatusForDisplay ="Nope!";

StringBuilder TheWordIntended = new StringBuilder();
List<Point> TempRectangles = new List<Point>();
TheWordIntended.Clear();
if (StartX < EndX && StartY == EndY) // Right line drawn.
    for (int i = StartX; i <= EndX; i++)
    {
        TheWordIntended.Append(WORDS_IN_BOARD[i - 1, StartY - 1].ToString());
        TempRectangles.Add(new Point(i * SizeFactor, StartY * SizeFactor));
    }
else if (StartX > EndX && StartY == EndY) // Left line drawn.
.................................
.................................
.................................

以类似的方式,它会检查所有其他方向。请注意,这是一个 `IF`-`ELSE IF` 块;一旦匹配了一个方向,就只会添加该方向的单词,而不会进入其他块。

构成一个单词后,它会检查该单词是否在单词列表中。如果存在且尚未找到,则将单词添加到 `WORDS_FOUND LIST` 并更新分数。

4) 计分

对于计分,有一个分数文件。如果缺少,它会创建一个带有当前分数和类别。在这里,所有分数都合并在一个大的管道分隔的 `string` 中,然后该 `string` 被加密并放入文件中。分数有四个属性:

class ScoreEntity
{
    public string Category { get; set; }
    public string Scorer { get; set; }
    public int Score { get; set; }
    public DateTime ScoreTime { get; set; }
..............
..............

它允许每个类别最多有 `MAX_SCORES`(在此文章中为 14)个分数。首先,它将所有分数加载到分数列表中,然后获取当前类别的排序子集(最高分在前)。在此子集中,它检查当前分数是否大于或等于 (>=) 任何可用分数。如果是,则插入当前分数。之后,它检查子集计数是否超过 `MAX_SCORES`,如果超过,则删除最后一个。因此,最后一个分数被删除,列表始终包含 `MAX_SCORES` 个分数。这在 `CheckAndSaveIfTopScore()` 方法中完成。

同样,如果有人篡改分数文件,它将简单地开始新的计分。不允许篡改。

5) 显示隐藏单词

如果时间用完(或应用了秘籍),游戏将以绿色显示单词。首先,它获取玩家未能找到的单词。这是在这里完成的。

List<string> FailedWords = new List<string>();
foreach (string Word in WORD_ARRAY)
    if (WORDS_FOUND.IndexOf(Word) == -1)
        FailedWords.Add(Word);

然后它遍历这些失败的单词位置并构成相应的失败矩形。最后,它通过使表单无效来调用表单的 `paint` 方法。

foreach (string Word in FailedWords)
{
    WordPosition Pos = TheGameEngine.ObtainFailedWordPosition(Word);

    if (Pos.Direction == GameEngine.Direction.Right) // Right.
        for (int i = Pos.PlacementIndex_X + 1, j = Pos.PlacementIndex_Y + 1, 
             k = 0; k < Pos.Word.Length; i++, k++)
            FailedRectangles.Add(new Point(i * SizeFactor, j * SizeFactor));
    else if (Pos.Direction == GameEngine.Direction.Left) // Left.
        for (int i = Pos.PlacementIndex_X + 1, j = Pos.PlacementIndex_Y + 1, 
             k = 0; k < Pos.Word.Length; i--, k++)
            FailedRectangles.Add(new Point(i * SizeFactor, j * SizeFactor));
    else if (Pos.Direction == GameEngine.Direction.Down) // Down.
        for (int i = Pos.PlacementIndex_X + 1, j = Pos.PlacementIndex_Y + 1, 
             k = 0; k < Pos.Word.Length; j++, k++)
            FailedRectangles.Add(new Point(i * SizeFactor, j * SizeFactor));
    else if (Pos.Direction == GameEngine.Direction.Up) // Up.
        for (int i = Pos.PlacementIndex_X + 1, j = Pos.PlacementIndex_Y + 1, 
             k = 0; k < Pos.Word.Length; j--, k++)
            FailedRectangles.Add(new Point(i * SizeFactor, j * SizeFactor));
    else if (Pos.Direction == GameEngine.Direction.DownLeft) // Down left word.
        for (int i = Pos.PlacementIndex_Y + 1, j = Pos.PlacementIndex_X + 1, 
             k = 0; k < Pos.Word.Length; i--, j++, k++)
            FailedRectangles.Add(new Point(i * SizeFactor, j * SizeFactor));
    else if (Pos.Direction == GameEngine.Direction.UpLeft) // Up left word.
        for (int i = Pos.PlacementIndex_Y + 1, j = Pos.PlacementIndex_X + 1, 
             k = 0; k < Pos.Word.Length; i--, j--, k++)
            FailedRectangles.Add(new Point(i * SizeFactor, j * SizeFactor));
    else if (Pos.Direction == GameEngine.Direction.DownRight) // Down right word.
        for (int i = Pos.PlacementIndex_X + 1, j = Pos.PlacementIndex_Y + 1, 
             k = 0; k < Pos.Word.Length; i++, j++, k++)
            FailedRectangles.Add(new Point(i * SizeFactor, j * SizeFactor));
    else if (Pos.Direction == GameEngine.Direction.UpRight) // Up Right word.
        for (int i = Pos.PlacementIndex_X + 1, j = Pos.PlacementIndex_Y + 1, 
             k = 0; k < Pos.Word.Length; i++, j--, k++)
            FailedRectangles.Add(new Point(i * SizeFactor, j * SizeFactor));
}
Invalidate();

6) 摘要显示

目的是在游戏结束时(无论是否成功找到所有单词,或未能找到所有单词)向玩家显示得分摘要。这在 `GameBoard` 表单的代码文件的 `DisplayScoreDetails()` 方法中完成。另一方面,它捕获棋盘单词网格区域的快照(在当前情况下——带有成功和失败的颜色),并将其作为内存流传递给 `ScoreDetails` 表单。

private void DisplayScoreDetails()
{
    MemoryStream MS = new MemoryStream();
    CaptureGameScreen(ref MS);

    ScoreDetails ScoreDetailsObj = new ScoreDetails(TheGameEngine.WordPositions, 
    GameEngine.REMAINING_TIME_BONUS_FACTOR, TheGameEngine.WORDS_FOUND, Words, 
    Clock.TimeLeft, TheGameEngine.CurrentScore, ref MS);
    ScoreDetailsObj.MdiParent = Parent.FindForm();
    ScoreDetailsObj.Show();
}

private void CaptureGameScreen(ref MemoryStream MS)
{
    using (Bitmap bitmap = new Bitmap(GridSize * SizeFactor + 2, GridSize * SizeFactor + 2))
    {
        using (Graphics g = Graphics.FromImage(bitmap))
        {
            if (Screen.PrimaryScreen.Bounds.Width >= 1600)
                g.CopyFromScreen(new Point(Bounds.Left + SizeFactor + 
                ResponsiveObj.GetMetrics(10), Convert.ToInt16(Bounds.Top + 
                (SizeFactor * 3.25))), Point.Empty, Bounds.Size);
            else if (Screen.PrimaryScreen.Bounds.Width > 1200)
                g.CopyFromScreen(new Point(Bounds.Left + SizeFactor + 
                ResponsiveObj.GetMetrics(10), Convert.ToInt16(Bounds.Top + 
                (SizeFactor * 3.85))), Point.Empty, Bounds.Size);
            else if (Screen.PrimaryScreen.Bounds.Width > 1100)
                g.CopyFromScreen(new Point(Bounds.Left + SizeFactor + 
                ResponsiveObj.GetMetrics(10), Convert.ToInt16(Bounds.Top + 
                (SizeFactor * 4.2))), Point.Empty, Bounds.Size);
            else
                g.CopyFromScreen(new Point(Bounds.Left + SizeFactor + 
                ResponsiveObj.GetMetrics(10), Convert.ToInt16(Bounds.Top + 
                (SizeFactor * 4.65))), Point.Empty, Bounds.Size);
        }
        bitmap.Save(MS, ImageFormat.Bmp);
    }
}

响应式对象的作用可以在本文的参考部分找到;此处不讨论。简而言之,它提供了一种巧妙的方法来根据不同的分辨率缩放控件——有点像电影《神偷奶爸》中的“收缩射线”! :)

我未能找到一种通用的方法来精确提取不同分辨率下的网格区域。作为一种替代方法,尝试了不同的分辨率来获取单词网格的良好捕获,然后将其传递给详细信息表单。详细信息表单然后重新生成图像并相应地显示得分摘要。这有助于玩家理解游戏用于计分的计算。一个值得关注的点是,制表符(`\t`)不起作用;也许它在标签文本中不起作用。

private void LoadScoreDetails()
{
    StringBuilder SBuilder = new StringBuilder();
    SBuilder.Append("Score for found words:\n");
    SBuilder.Append("======================\n");
    int Augmenter, Len;
    foreach(string Wrd in WORDS_FOUND)
    {
        Augmenter = WordPositions.Find(p => p.Word.Equals(Wrd)).ScoreAugmenter;
        Len = Wrd.Length;
        SBuilder.Append(Wrd + ", Score:\t\t" + Len.ToString() + " x " + 
        WordPositions.Find(p => p.Word.Equals(Wrd)).ScoreAugmenter.ToString() + 
        " = " + (Len * Augmenter).ToString() + "\n");
    }

    SBuilder.Append("\nFailed Words:\n");
    SBuilder.Append("======================\n");

    string[] FailedWords = WORD_ARRAY.Where(p => !WORDS_FOUND.Any
                           (p2 => p2.Equals(p))).ToArray();
    if (FailedWords.GetUpperBound(0) < 0)
        SBuilder.Append("None\n");
    else
        foreach(string Word in FailedWords)
            SBuilder.Append(Word + "\n");
    SBuilder.Append("\nTimer bonus:\t\t");
    SBuilder.Append("======================\n");
    if (RemainingTime == 0)
        SBuilder.Append("None\n");
    else SBuilder.Append(RemainingTime.ToString() + " x " + 
         REMAINING_TIME_MULTIPLIER.ToString() + " = " + 
         (RemainingTime * REMAINING_TIME_MULTIPLIER).ToString() + "\n");

    SBuilder.Append("======================\n");
    SBuilder.Append("Total score:\t\t" + TotalScore.ToString());

    ScoreDetailslabel.Text = SBuilder.ToString();
}

此时未提供保存快照的功能。当然,可以应用与捕获游戏棋盘相同的方法。

7) 秘籍

这是一个小问题。它在 `keyup` 事件上工作,其中任何按键都被捕获到两个中间变量中——`CheatCodeForIncreasingTime` 和 `CheatCodeForFlashUndiscoveredWords`。实际上,按键是以玩家在游戏窗口中输入的方式组合起来的。然后它检查代码是否匹配任何可用的秘籍(‘`mambazamba`’ 或 ‘`flash`’)。例如,如果玩家按下 '`m`' 和 '`a`',那么它们会作为 '`ma`' 保存在 `CheatCodeForIncreasingTime` 变量中(因为 '`ma`' 仍然匹配秘籍模式)。类似地,如果它匹配 `CHEAT_CODE` 的模式,我们会向其添加连续变量。但是,一旦它不匹配模式(例如,“`mambi`”),它就会重新开始。

由于游戏目前有两个秘籍,因此需要明确地为两者都进行处理。这就是为什么按键保存在两个单独的变量中,并单独检查匹配项。哪个匹配,它就会触发相应的秘籍操作。

最后,如果匹配“`mambazamba`”,则激活第一个秘籍(字面意思是,它将剩余时间增加 100 秒),并应用惩罚(从当前分数中扣除 50 分)。

另一方面,如果匹配“`flash`”,则激活第二个秘籍(这将使所有未发现的单词在棋盘上闪烁 1 秒然后再次隐藏它们),并应用相同的惩罚。

public enum CHEAT_TYPE { INCREASE_TIME, FLASH_WORDS, NONE};

CheatType = CHEAT_TYPE.NONE;
CheatCodeForIncreasingTime += CheatCode;

if (CHEAT_CODE_FOR_INCREASING_TIME.IndexOf(CheatCodeForIncreasingTime) == -1)  // Cheat code 
          // didn't match with any part of the cheat code starting from the first letter.
    CheatCodeForIncreasingTime = (CheatCode);          // Hence, erase it to start over.
else if (CheatCodeForIncreasingTime.Equals(CHEAT_CODE_FOR_INCREASING_TIME) && 
         WordsFound != MAX_WORDS)
{
    CheatType = CHEAT_TYPE.INCREASE_TIME;
    return true;
}

CheatCodeForFlashUndiscoveredWords += CheatCode;
if (CHEAT_CODE_FOR_UNDISCOVERED_WORDS.IndexOf
    (CheatCodeForFlashUndiscoveredWords) == -1)        // Cheat code didn't match with 
                                                       // any part of the cheat code.
    CheatCodeForFlashUndiscoveredWords = (CheatCode);  // Hence, erase it to start over.
else if (CheatCodeForFlashUndiscoveredWords.Equals(CHEAT_CODE_FOR_UNDISCOVERED_WORDS) && 
                                                   WordsFound != MAX_WORDS)
{
    CheatType = CHEAT_TYPE.FLASH_WORDS;
    return true;
}
return false;

这里值得注意的有趣之处在于,我们必须使用 `WordsListView` 的 `KeyUp` 事件而不是表单。这是因为在加载游戏窗口后,列表框具有焦点,而不是表单。

环境

使用 Visual Studio 2015 IDE 编码,.NET Framework 4.5。这不是移动版本——需要一台机器来玩。

关注点

为了强制重绘窗口,我们需要调用窗口的 `Invalidate()` 方法。还需要通过调整表单的顶部和左侧位置来校准鼠标坐标。有趣的是,表单的坐标定义为:X 是屏幕顶部到窗口的距离,Y 是屏幕左侧到窗口的距离。然而,鼠标坐标的定义方式相反:X 是窗口左侧到鼠标的距离,Y 是窗口顶部到鼠标的距离。因此,为了校准,我们需要仔细调整。

private void GameBoard_MouseMove(object sender, MouseEventArgs e)
{
    try
    {
        if (e.Button == MouseButtons.Left)
        {
            if (Points.Count > 1)
                Points.Pop();
            if (Points.Count > 0)
                Points.Push(e.Location);

            // Form top = X = Distance from top, left = Y = Distance from left.
            // However mouse location X = Distance from left, Y = Distance from top.

            // Need an adjustment to exact the location.
            Point TopLeft = new Point(Top, Left);
            Point DrawFrom = new Point(TopLeft.Y + Points.ToArray()[0].X + 10, 
                                       TopLeft.X + Points.ToArray()[0].Y + 80);
            Point DrawTo = new Point(TopLeft.Y + Points.ToArray()[1].X + 10, 
                                     TopLeft.X + Points.ToArray()[1].Y + 80);

            ControlPaint.DrawReversibleLine(DrawFrom, DrawTo, Color.Black); // draw new line
        }
    }
   

通过 jrobb229 的消息,我们发现了另一个重要且有趣的现象,关于 ENTER 键的行为。最初的版本对玩家想要输入小于 3 个字符的单词的 `datagrid` 进行了即时检查。它实际上处理了长度的逻辑检查,但没有办法阻止光标移到下一个单元格。这是它的实现方式。

我仍然找不到一种方法来应对这种行为。所以我添加了另一种方法,在后面的验证中进行长度检查。我对绕过感到不满意;不过,只是提供了一种当前错误的替代方案,并希望很快能找到完美的解决方法。

故障

我发现了一个小故障,如果机器上有多个显示器。如果游戏在一个窗口中加载,然后移动到另一个窗口,鼠标拖动会在第一个窗口留下刮痕。但不必惊慌,游戏关闭后它会消失。

如果游戏棋盘从主窗口移动到另一个窗口,并且截图代码试图捕获主屏幕的给定区域,则会出现另一个故障。原因与当前版本选择主屏幕进行屏幕捕获相同。在捕获时,没有提供检查游戏棋盘已移动到何处。

免责声明

除了最初的版本,游戏已经被重构为更面向对象的方法。然而,这是一个永无止境的过程,因此可能还有更多改进的空间。

我没有遵循任何命名约定。我个人偏好使用一个名称,该名称可能能够说明其意图,在鼠标悬停在名称上时,我们可以轻松理解其类型;所以为什么要把一个变量命名得像‘`strStatusLabel`’那样沉重呢?可能会有争议,但这并不是本文的目的。

致谢

感谢

  • Member 10014441 报告了‘`CalibrationFactor`’错误。
  • jrobb229 报告了最高分错误,以及 `datagridview` ENTER 键异常错误。也感谢改进建议。
  • sx2008 提出关于减小项目大小的建议。
  • 其他人感谢您的游玩和评论 :)

未来工作

剩余时间应根据难度级别进行调整。目前,固定的 720 秒并不能真正区分难度级别,因为较容易的方向和较难的方向都具有相同的时间限制。另一方面,这可能被认为是合理的,因为玩家选择了困难的游戏,因此时间应该保持不变。

详细信息屏幕可以捕获并保存为图像以供将来参考。捕获屏幕的代码已经存在。

可能需要一种通用的方法来在不同分辨率下捕捉游戏棋盘。目前,这是一种带有 `IF`-`ELSE` 条件的粗略方法。

`datagridview` 的 ENTER 键按下实际上没有触发。这是一个奇怪的行为,很难处理,特别是当我们想在 ENTER 键按下时查看发生了什么(例如,检查单词长度是否小于 3 个字符)。在这种情况下,‘`e.Handled`’不适用。在此版本中,此问题通过替代方法得以解决。我对绕过感到不太满意,但目前只是采取了这种方法来使其正常工作。这是一个真正的编程优化,可以得到解决。

摘要

这是一个单词益智游戏,具有预设单词、自定义单词、按类别计分的功能。

参考文献

查找一个列表中不存在于另一个列表中的项

历史

  • 2016 年 10 月 10 日:首次发布
  • 2016 年 10 月 17 日/18 日:错误修复、响应式设计、在 CodeProject 中重新格式化代码
  • 2016 年 10 月 20 日:从可下载文件中删除安装程序,调整可下载项目大小并将可执行文件存储在该可下载 zip 文件中,添加了一个参考。
  • 2016 年 11 月 15 日:使其成为全方位的(8 个方向),提供了两个秘籍,并使用更好的 OOP 方法重构了整个项目,更好的计分。即使在时限内未找到所有单词,也可以进行计分,但仍是最高分。更好的得分摘要以供得分参考。
© . All rights reserved.