用 C#.NET 編寫的一個完整字謎遊戲

2017-07-10    分類:.NET開發、程式設計開發、首頁精華1人評論發表於2017-07-10

本文由碼農網 – 小峰原創翻譯,轉載請看清文末的轉載要求,歡迎參與我們的付費投稿計劃

介紹

字謎遊戲,可能你在許多益智書中都曾看到過。試著在電腦上用不同類別的內容寫字謎遊戲,並且有自定義字詞去玩也是很有意思的。

背景

我很早以前使用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
翻譯作者:碼農網 – 小峰
轉載必須在正文中標註並保留原文連結、譯文連結和譯者等資訊。]

相關文章