用 C#.NET 编写的一个完整字谜游戏

本文由码农网 – 小峰原创翻译,转载请看清文末的转载要求,欢迎参与我们的付费投稿计划

介绍

字谜游戏,可能你在许多益智书中都曾看到过。试着在电脑上用不同类别的内容写字谜游戏,并且有自定义字词去玩也是很有意思的。

背景

我很早以前使用Turbo C编码游戏,但我丢失了代码。我觉得用C#.NET让它复活将是一件很伟大的事情。该语言在内存、GC、图形方面提供了很多灵活性,而这些是我在使用C语言的时候必须小心处理的。但是在C语言中的明确关注,会让我们学到很多(这就是为什么C语言被称为“上帝的编程语言”的原因)。另一方面,因为C#.NET照顾到了这些,所以我可以专注于其他地方的增强,例如字的方向,重叠,作弊码,计分,加密等。所以在欣赏两种语言的时候需要有一个平衡。

在题目中我之所以说它是“完整的”,原因如下:

1)它有一些类别的预设词。

2)它在加密文件中保存单词和分数,这样就没有人可以篡改文件。如果要篡改,那么它将恢复到预设并从头开始计分。

3)它有作弊码,但作弊会不利于得分,且显然作弊一旦应用会使分数归零。

4)它有一个计分机制。

使用代码

游戏提供以下功能,具体我将在随后的章节中讨论:

1)载入类别和单词:从程序中硬编码的预设中加载单词。然而,如果玩家提供自定义的单词,那么游戏将自动把所有这些(连同预设)存储在文件中并从那里读取。

2)放在网格上:游戏将所有的单词随机地放在18×18的矩阵中。方向可以是水平,垂直,左下和右下,如上图中所示。

3)计分:对于不同类别,分数单独存储。分数的计算方式是单词的长度乘以乘法因子(这里为10)。与此同时,在找到所有的单词之后,剩余时间(乘以乘法因子)也会加到分数中。

4)显示隐藏的单词:如果时间用完之后,玩家依然找不到所有的单词,那么游戏会用不同的颜色显示没找到的单词。

5)作弊码:游戏在游戏板上提作弊码(mambazamba)。作弊码只简单地设置了一整天的时间(86,400秒)。但是,应用作弊码也会应用让此次运行的计分为零的惩罚。

1)载入类别和单词:

载入预设

我们有一个简单的用于持有类别和单词的类:

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|" +
...

我们使用加密在文件中写这些单词。所以没有人可以篡改文件。对于加密我使用了一个从这里借鉴的类。使用简单——你需要传递字符串和用于加密的加密密码。对于解密,你需要传递加密的字符串和密码。

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

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’这样两个词,因为前者包含在后者中。

我将简要介绍一下有效性检查。有3个关于最大长度、最小长度和SPACE输入(不允许空格)的即时检查。这通过将我们自定义的处理程序Control_KeyPress添加到单词条目网格的EditingControlShowingevent中来完成。

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 (e.KeyChar == (char)Keys.Enter)
{
    if (tb.Text.Length <= MIN_LENGTH)   // Checking length
    {
        MessageBox.Show("Words should be at least " + MAX_LENGTH + " characters long.");
        e.Handled = true;
        return;
    }
}
if (tb.Text.Length >= MAX_LENGTH)   // Checking 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个单词,并检查它们是否有无效字符。同时它也检查重复的单词。检查成功就把单词添加到列表中。最后,提交另一次迭代,以检查单词是否包含在另一个单词中(例如,不能有如’JAPAN’和’JAPANESE’这样的两个单词,因为前者包含在后者中)。通过下面的代码完成:

public bool CheckUserInputValidity(DataGridView WordsDataGridView, List<string> WordsByThePlayer)
{
    if (WordsDataGridView.Rows.Count != MAX_WORDS + 1)
    {
        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.
    {
        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)放在网格上:

在网格上放置单词

单词通过InitializeBoard()方法被放置在网格上。我们在字符矩阵(二维字符数组)WORDS_IN_BOARD中先放置单词。然后我们在网格中映射这个矩阵。遍历所有的单词。每个单词获取随机方向(水平/垂直/左下/右下)下的随机位置。此时,如果我们可视化的话,单词矩阵看起来会有点像下面这样。

放置通过PlaceTheWords()方法完成,获得4个参数——单词方向,单词本身,X坐标和Y坐标。这是一个关键方法,所以我要逐个解释这四个方向。

水平方向

对于整个单词,逐个字符地运行循环。首先它检查这个词是否落在网格之外。如果这是真的,那么它返回到调用过程以生成新的随机位置和方向。

然后,它检查当前字符是否可能与网格上的现有字符重叠。如果发生这种情况,那么检查它是否是相同的字符。如果不是相同的字符,那就返回到调用方法,请求另一个随机位置和方向。

在这两个检查之后,如果放置是一种可能,那么就把单词放置在矩阵中,并且通过方法StoreWordPosition()将列表中的位置和方向存储在WordPositions中。

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;

垂直/左下/右下方向

相同的逻辑适用于为这3个方向找到单词的良好布局。它们在矩阵位置和边界检查的增量/减量方面不同。

在所有的单词被放置在矩阵中之后,FillInTheGaps()方法用随机字母填充矩阵的其余部分。此时窗体打开并触发Paint()事件。在这个事件上,我们绘制最终显示为40×40像素矩形的线。然后我们将我们的字符矩阵映射到board上。

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()方法简单地把我们的字符矩阵放在board上。我们使用来自MSDN的绘图代码。这遍历矩阵中的所有字符,将它们放置在40×40矩形的中间,边距调整为10像素。

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

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

单词发现和有效性检查

鼠标点击位置和释放位置存储在点列表中。对鼠标按钮释放事件(GameBoard_MouseUp())调用CheckValidity()方法。同时,当用户在左键按下的同时拖动鼠标时,我们从起始位置绘制一条线到鼠标指针。这在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 + 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

单词的有效性在CheckValidity()方法中检查。它通过抓取所有的字母来制定单词,字母通过使用鼠标查看相应的字符矩阵来绘制。然后检查是否真的匹配单词列表中的单词。如果匹配,则通过将单元格着色为浅蓝色并使单词列表中的单词变灰来更新单元格。

以下是抓取行开始和结束位置的代码片段。首先它检查行是否落在边界之外。然后它制定单词并且存储矩阵的坐标。类似地,它检查垂直,左下和右下单词,并尝试相应地匹配。如果这真的匹配,那么我们通过AddCoordinates()方法将临时矩形存储在我们的ColouredRectangles点列表中。

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

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

if (StartX > GridSize || EndX > GridSize || StartY > GridSize || EndY > GridSize || // Boundary checks.
    StartX <= 0 || EndX <= 0 || StartY <= 0 || EndY <= 0)
{
    StatusLabel.Text = "Nope!";
    StatusTimer.Start();
    return;
}

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

3)计分:

对于计分,我们有计分文件。如果缺少,则使用当前分数和类别创建一个。这里,再次,所有的分数被组合在一个大的管道分隔的字符串中,然后该字符串被加密并放入文件。我们有四个实体。

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

最多允许一个类别14个分数。首先加载分数列表中的所有分数,然后获得当前分类分数的排序子集。在该子集中,检查当前分数是否大于任何可用的分数。如果是,则插入当前分数。之后,检查子集数是否超过14,如果超过了,就消除最后一个。所以最后的得分消失了,列表总是有14个分数。这在CheckAndSaveIfTopScore()方法中完成。

这里,再次,如果有人篡改得分文件,那么它只会开始一个新的得分。不允许篡改。

4)显示隐藏的单词:

如果时间用完了,那么游戏用绿色显示单词。首先,获取玩家找不到的单词。可以是这样的

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 = WordPositions.Find(p => p.Word.Equals(Word));

    if (Pos.Direction == Direction.Horizontal) // Horizontal word.
        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 * 40, j * 40));
    else if (Pos.Direction == Direction.Vertical) // Vertical word.
        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 * 40, j * 40));
    else if (Pos.Direction == 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 * 40, j * 40));
    else if (Pos.Direction == 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 * 40, j * 40));
}
Invalidate();

5)作弊码:

这是一件小事了。这工作在keyup事件上,这个事件抓取所有的击键到CheatCode变量。实际上,我们合并玩家在游戏窗口上输入的击键,并看看代码是否与我们的CHEAT_CODE(mambazamba)匹配。例如,如果玩家按下“m”和“a”,那么我们在CheatCode变量中将它们保持为’ma’(因为,ma仍然匹配cheatcode模式)。类似地,如果它匹配CHEAT_CODE的模式,则添加连续变量。然而,一旦它不能匹配模式(例如,’mambi’),则重新开始。

最后,如果匹配,则激活作弊码(将剩余时间提高到完整一天,即86,400秒),并应用惩罚。

CheatCode += e.KeyCode.ToString().ToUpper();
if (CHEAT_CODE.IndexOf(CheatCode) == -1)    // Cheat code didn't match with any part of the cheat code.
    CheatCode = ("" + e.KeyCode).ToUpper();                         // Hence erase it to start over.
else if (CheatCode.Equals(CHEAT_CODE) && WORDS_FOUND.Count != MAX_WORDS)
{
    Clock.TimeLeft = 86400;                 // Cheat code applied, literally unlimited time. 86400 seconds equal 1 day.
    ScoreLabel.Text = "Score: 0";
    StatusLabel.Text = "Cheated! Penalty applied!!";
    StatusTimer.Start();
    CurrentScore = 0;
    Invalidate();

这里有趣的是,我们必须使用WordsListView的KeyUp事件而不是窗体。这是因为在加载游戏窗口后,列表框有焦点,而不是窗体。

环境

使用Visual Studio 2015 IDE编码。这不是一个移动版本——需要PC电脑来玩这个游戏。

免责声明

这不是一个OOP项目,它遵循程序性编程,虽然有些地方应用了OOP并且将进程委托给类对象。项目是用RAD方法完成的,以便使事情进行。它需要重构。此外,我没有遵循任何命名约定。我个人的偏好是名字能够说明意图,在将鼠标悬停在名字上面的时候,你可以自动地了解类型。我的意思是,变量’TheWordIntended’根据我没有遵循的标准命名约定应该有一个像’strTheWordIntended’这样的名字。这些都是可以被重构的。

未来的工作

有很多事情可以做——应用设计模式,OOP。作为软件开发的本质,重构是必须的。

兴趣点

要强制重绘窗口,我们需要调用窗口的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
        }
    }

瑕疵

我发现了一个小问题,如果一台机器有多个监测的话。如果游戏在一个窗口中加载,并且它被移动到另一个窗口的话,则鼠标拖动在第一个窗口上保持疤痕标记。不必恐慌,它在游戏关闭后擦除。

bug

到目前为止没有发现任何bug。如果你发现bug的话,欢迎留言。也欢迎提出你的意见。

概要

这是一个字母拼图游戏,可预设单词,自定义单词词,对单个词类别计分。

参考文献

Drawing letter on a form

Drawing a straight line on a form – Link 1

Drawing a straight line on a form – Link 2

Colour a rectangle

Datagrid font sizing

Datagrid column width setup

Datagrid column header setup

Datagrid key press event handler

Bind datagrid columns with predefined columns

String ciphering

Fixing maximum number of allowed rows in a DataGridView

译文链接:http://www.codeceo.com/article/a-complete-word-puzzle-game-in-csharp.html
英文原文:A Complete Word Puzzle Game in C#.NET
翻译作者:码农网 – 小峰
转载必须在正文中标注并保留原文链接、译文链接和译者等信息。]