Heim  >  Artikel  >  Backend-Entwicklung  >  So schreiben Sie mit C# einen Beispielcode für ein vollständiges Worträtselspiel

So schreiben Sie mit C# einen Beispielcode für ein vollständiges Worträtselspiel

黄舟
黄舟Original
2017-07-18 10:21:462726Durchsuche

Einführung

Worträtselspiel, Sie haben es vielleicht schon in vielen Rätselbüchern gesehen. Es würde auch Spaß machen, Kreuzworträtsel auf Ihrem Computer zu schreiben, wobei Sie verschiedene Inhaltskategorien verwenden und mit benutzerdefinierten Wörtern spielen können.

Hintergrund

Ich habe vor langer Zeit ein Spiel mit Turbo C programmiert, aber den Code verloren. Ich denke, es wäre großartig, es mit C#.NET wiederzubeleben. Die Sprache bietet viel Flexibilität in Bezug auf Speicher, GC und Grafik, worauf ich bei der Verwendung von C achten muss. Aber indem wir C explizite Aufmerksamkeit schenken, können wir viel lernen (deshalb wird C auch „Gottes Programmiersprache“ genannt). Da sich andererseits C#.NET um diese kümmert, kann ich mich auf Verbesserungen an anderer Stelle konzentrieren, wie z. B. Wortausrichtung, Überlappung, Cheat-Codes, Bewertung, Verschlüsselung usw. Es muss also ein Gleichgewicht bei der Wertschätzung zweier Sprachen bestehen.

Der Grund, warum ich im Titel gesagt habe, dass es „vollständig“ ist, ist folgender:

1) Es gibt einige Kategorien voreingestellter Wörter.

2) Es speichert Wörter und Partituren in verschlüsselten Dateien, sodass niemand die Dateien manipulieren kann. Wenn es manipuliert wird, wird es auf die Standardwerte zurückgesetzt und die Bewertung beginnt von vorne.

3) Es gibt Cheat-Codes, aber Betrug wirkt sich negativ auf die Punktzahl aus, und sobald der Cheat angewendet wird, wird die Punktzahl natürlich auf Null zurückgesetzt.

4) Es verfügt über einen Bewertungsmechanismus.

Codes verwenden

Das Spiel bietet die folgenden Funktionen, die ich in den folgenden Kapiteln besprechen werde:

1) Kategorien und Wörter laden: vorcodiert, fest codiert vom Programm Load Wörter im Gerät. Wenn der Spieler jedoch benutzerdefinierte Wörter bereitstellt, speichert das Spiel diese (zusammen mit den Voreinstellungen) automatisch in einer Datei und liest sie von dort aus.

2) Im Raster platzieren: Das Spiel platziert alle Wörter zufällig in einer 18×18-Matrix. Die Richtung kann horizontal, vertikal, unten links und unten rechts sein, wie im Bild oben gezeigt.

3) Bewertung: Für verschiedene Kategorien werden die Bewertungen separat gespeichert. Die Punktzahl errechnet sich aus der Länge des Wortes multipliziert mit einem Multiplikationsfaktor (hier 10). Gleichzeitig wird, nachdem alle Wörter gefunden wurden, auch die verbleibende Zeit (multipliziert mit dem Multiplikationsfaktor) zum Punktestand addiert.

4) Versteckte Wörter anzeigen: Wenn der Spieler nach Ablauf der Zeit immer noch nicht alle Wörter finden kann, zeigt das Spiel die nicht gefundenen Wörter in verschiedenen Farben an.

5) Cheat-Codes: Das Spiel erwähnt Cheat-Codes (Mambazamba) auf dem Spielbrett. Der Cheat-Code legt einfach die Zeit für einen ganzen Tag fest (86.400 Sekunden). Allerdings führt die Anwendung eines Cheat-Codes auch zu einer Strafe, die dazu führt, dass der Run-Score auf Null sinkt.

1) Kategorien und Wörter laden:

Voreinstellung laden

Wir haben eine einfache Klasse zum Speichern von Kategorien und Wörtern:

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

Wir haben einige voreingestellte Kategorien und Wörter wie folgt. Die Voreinstellungen sind alle durch Pipes getrennt, wobei jedes 15. Wort der Kategoriename ist und die folgenden Wörter Wörter in dieser Kategorie sind.

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

Wir schreiben diese Wörter verschlüsselt in die Datei. So kann niemand die Datei manipulieren. Zur Verschlüsselung habe ich eine von hier entlehnte Klasse verwendet. Einfach zu verwenden – Sie müssen zur Verschlüsselung eine Zeichenfolge und ein verschlüsseltes Passwort übergeben. Zur Entschlüsselung müssen Sie die verschlüsselte Zeichenfolge und das Passwort übergeben.

Wenn die Datei vorhanden ist, lesen wir die Kategorien und Wörter von dort, andernfalls speichern wir die Voreinstellung (und die vom Spieler definierten Wörter) und lesen aus der Voreinstellung. Dies geschieht im folgenden Code:

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();
}

Die ReadFromFile()-Methode liest einfach aus der Datei, in der die Wörter gespeichert sind. Zunächst wird versucht, die aus der Datei gelesene Zeichenfolge zu entschlüsseln. Wenn dies fehlschlägt (ermittelt durch die zurückgegebene leere Zeichenfolge), wird eine Meldung über das Problem angezeigt und dann von der integrierten Voreinstellung neu geladen. Andernfalls liest es aus Zeichenfolgen, unterteilt sie in Kategorien und Wörter und fügt sie in eine Wortliste ein. Jedes 15. Wort ist eine Kategorie, und nachfolgende Wörter sind Wörter unter dieser Kategorie.

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);
    }
}

Benutzerdefinierte Wörter des Spielers speichern

Das Spiel kann von Spielern bereitgestellte benutzerdefinierte Wörter bereitstellen. Das Gerät befindet sich im selben Ladefenster. Wörter sollten mindestens 3 und höchstens 10 Zeichen lang sein und 14 Wörter erfordern – nicht mehr und nicht weniger. Anweisungen finden Sie auf dem Etikett. Außerdem kann ein Wort kein Unterbestandteil eines anderen Wortes sein. Zum Beispiel: Es kann nicht zwei Wörter wie „JAPAN“ und „JAPANESE“ geben, da ersteres in letzterem enthalten ist.

Ich gebe Ihnen eine kurze Einführung in die Gültigkeitsprüfung. Es gibt 3 spontane Prüfungen für maximale Länge, minimale Länge und SPACE-Eingabe (keine Leerzeichen erlaubt). Dies geschieht durch Hinzufügen unseres benutzerdefinierten Handlers Control_KeyPress zum EditingControlShowingevent des Worteingaberasters.

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

Jedes Mal, wenn der Benutzer etwas eingibt, wird der Handler aufgerufen und prüft die Gültigkeit. Vervollständigen Sie es wie folgt:

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(&#39; &#39;))  // 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 = { &#39;:&#39;, &#39;;&#39;, &#39;@&#39;, &#39;\&#39;&#39;, &#39;"&#39;, &#39;{&#39;, &#39;}&#39;, &#39;[&#39;, &#39;]&#39;, &#39;|&#39;, &#39;\\&#39;, &#39;<&#39;, &#39;>&#39;, &#39;?&#39;, &#39;,&#39;, &#39;.&#39;, &#39;/&#39;,
                            &#39;`&#39;, &#39;1&#39;, &#39;2&#39;, &#39;3&#39;, &#39;4&#39;, &#39;5&#39;, &#39;6&#39;, &#39;7&#39;, &#39;8&#39;, &#39;9&#39;, &#39;0&#39;, &#39;-&#39;, &#39;=&#39;, &#39;~&#39;, &#39;!&#39;, &#39;#&#39;, &#39;$&#39;,
                            &#39;%&#39;, &#39;^&#39;, &#39;&&#39;, &#39;*&#39;, &#39;(&#39;, &#39;)&#39;, &#39;_&#39;, &#39;+&#39;};   //&#39;
    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: &#39;" + Itm.Cells[0].Value.ToString() + "&#39;");
            return false;
        }
        if (WordsByThePlayer.IndexOf(Itm.Cells[0].Value.ToString()) != -1)
        {
            MessageBox.Show("Can&#39;t have duplicate word in the list. The duplicate word is: &#39;" + Itm.Cells[0].Value.ToString() + "&#39;");
            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&#39;t have a word as a sub-part of another word. Such words are: &#39;" + WordsByThePlayer[i] + "&#39; and &#39;" + WordsByThePlayer[j] + "&#39;");
                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] != &#39;\0&#39;)
        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] != &#39;\0&#39;)
        {
            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&#39;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事件而不是窗体。这是因为在加载游戏窗口后,列表框有焦点,而不是窗体。

Das obige ist der detaillierte Inhalt vonSo schreiben Sie mit C# einen Beispielcode für ein vollständiges Worträtselspiel. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!

Stellungnahme:
Der Inhalt dieses Artikels wird freiwillig von Internetnutzern beigesteuert und das Urheberrecht liegt beim ursprünglichen Autor. Diese Website übernimmt keine entsprechende rechtliche Verantwortung. Wenn Sie Inhalte finden, bei denen der Verdacht eines Plagiats oder einer Rechtsverletzung besteht, wenden Sie sich bitte an admin@php.cn