-- 简书作者 谢恩铭 转载请注明出处

第二部分第十课: 实战"悬挂小人"游戏 答案


经过上一课C语言探索之旅 | 第二部分第九课: 实战"悬挂小人"游戏之后,相信大家都或多或少有写自己的“悬挂小人”的游戏代码吧。

这一课我们就来"终结"这个游戏吧 (听着怎么有点吓人...)。

Yes, you are terminated.

解方(1. 游戏的代码)


如果你开始阅读这里,说明:

  • 或者你写完了游戏,想来看看我们怎么写

  • 或者你没完成这个游戏,想来看看怎么写

不管您是哪种情况,小编都会介绍一下如何来完成这个游戏。

“说不说在我,听不听在您”~

事实上,小编自己花了比想象中更多的时间来完成这游戏。

人生总是这样的,“理想丰满,现实骨感;看似美满,人艰不拆”。

但是,我还是坚信大家是有能力独自完成这个小游戏的(如果你认真学习了之前的C语言课程),可以去查阅网上资料,花点时间(几十分钟,几小时,几天?),这并不是一次竞赛,所以不用着急。

我更希望您花了不少时间,最终实现了这个游戏; 比之您只花5分钟,然后就来看答案要好很多。

千万不要觉得小编是一蹴而就写成这个游戏的,这个游戏虽小,但也还没简单到可以在脑中构思好一切,然后“下笔如有神”: 我也是一步步写出来的。

我们将会分2步来介绍我们的解方:

  1. 首先我们会演示如何一步步写游戏的主体部分,一开始我们会只有一个猜测的单词,而且是固定的; 我选了BOTTLE(英语 “瓶子”的意思),因为我们要测试对于单词中有大于等于两个相同字母的情况是否处理正确了(BOTTLE中有2个T)。

  2. 然后我们会演示如何加入词库的处理程序,以便每一轮游戏可以从词库中随机抽取一个单词。

牢记: 重要的不是结果,而是我们思考的方式和过程。

分析main函数


大家都知道,我们的C语言程序都是由main函数作为入口的。

我们也不要忘了引入一些标准库的头文件: stdio.h, stdlib.h, ctype.h(为了toupper函数)。

因此,我们的程序一开始会是这样的:

#include <stdio.h>
#include <stdlib.h>
#include <ctype.h>

int main(int argc, char* argv[])
{
  return 0;
}

是不是很简单啊,慢慢来么。

我们的main函数将控制游戏的大部分运作,并且调用我们将要写的不少函数。

我们来声明一些必要的变量吧。这些变量也不是一次就能全部想到的,都是写一点,想到一些。“罗马不是一日建成的, 小人也不是一日能悬挂完的”。

#include <stdio.h>
#include <stdlib.h>
#include <ctype.h>

int main(int argc, char* argv[])
{
  char letter = 0; // 存储用户输入的字母
  char secretWord[] = "BOTTLE"; // 要猜测的单词
  int letterFound[6] = {0}; // 布尔值的数组. 数组的每一个元素对应猜测单词的一个字母。 0 = 还没猜到此字母, 1 = 已猜到字母
  int leftTimes = 7; // 剩余猜测次数 (0 = 失败)
  int i = 0; // 为了遍历数组,需要一个下标

  return 0;
}

上述的变量中,起到关键作用的就是letterFound这个int型数组了。这个数组用于表示猜测的单词中哪些字母已经猜到,哪些还没猜到。

一开始,我们实现得简单些:我们的单词BOTTLE有6个字母,因此我们的数组就固定是6个元素的数组。

如果元素为0,表示对应的那个字母还没猜到;如果为1,则表示已猜到。随着游戏的进行,这个数组的元素值会被修改。

例如,如果当下我们玩游戏直到:

B*TT*E

那么,letterFound这个数组的值应该是这样:

101101

之后我们要测试游戏的一轮是否已经胜利也就比较简单了: 只需要测试letterFound数组的所有元素是否都等于1。

我们就来写判断一轮是否胜利的函数吧,取名为win(英语 “胜利”的意思)好了。

int win(int letterFound[])
{
  int i = 0;
  int win = 1; // 1为胜利,0为失败

  for (i = 0 ; i < 6 ; i++)
  {
    if (letterFound[i] == 0)
      win = 0;
  }

  return win;
}

可以看到,我们的win函数的参数是一个int型数组,我们在main函数中调用win函数时,会将我们的letterFound数组传给它。

这个函数很简单: 遍历数组,只要还有一个元素为0,那游戏还没胜利; 如果所有元素都为1,则游戏胜利。

为了与此函数搭配,我们还需要写一个函数,起名叫researchLetter,这个函数将有两个功能:

  1. 返回一个布尔值(在C语言里用int型表示),用于表示所猜的字母是否存在于单词中。

  2. 更新letterFound数组的元素,如果所猜的字母在单词中,那么就把对应的元素值修改为1。

int researchLetter(char letter, char secretWord[], int letterFound[])
{
  int i = 0;
  int correctLetter = 0; // 0表示字母不在单词里,1表示字母在单词里

  // 遍历单词数组secretWord,以判断所猜字母是否在单词中
  for (i = 0 ; secretWord[i] != '\0' ; i++)
  {
    if (letter == secretWord[i]) // 如果字母在单词中
    {
      correctLetter = 1; // 表示猜对了一个字母
      letterFound[i] = 1; // 对于所有等于所猜字母的数组位置,都使其数值变为1
    }
  }

  return correctLetter;
}

researchLetter这个函数的好处还在于: 不会在找到第一个存在的字母后就停止,而会继续查找,所以对于像BOTTLE这样有两个字母相同的单词就可以一次揭示两个T了。

好,写完这两个函数(放在main函数后面),我们继续写我们的main函数。我们添加一句欢迎词:

printf("欢迎来到悬挂小人游戏!\n");

然后添加一个主循环,是一个while循环:

while (leftTimes > 0 && !win(letterFound))
{
}

每轮游戏在leftTimes(剩余猜测机会)大于0并且还没胜利的情况下,是不会停止的。

  • 如果剩余次数为0,则本轮游戏失败
  • 如果胜利,那本轮就赢了

在这两种情况下,都要停止游戏。

我们在while循环里添加如下代码:

printf("\n\n您还剩 %d 次机会", leftTimes);
printf("\n神秘单词是什么呢 ? ");

/* 我们显示猜测的单词,将还没猜到的字母用*表示例如 : *O**LE */
for (i = 0 ; i < 6 ; i++)
{
  if (letterFound[i]) // 如果第i+1个字母已经猜到
    printf("%c", secretWord[i]); // 打印出来
  else
    printf("*"); // 还没猜到,打印一个星号*
}

上面的代码用于:

  • 打印剩余机会数

  • 打印单词(其中还没猜到的字母用星号*表示)

接下来,我们写请求用户输入一个字母的代码:

printf("\n输入一个字母 : ");
letter = readCharacter();

还记得我们之前写的函数 readCharacter 吗?它用于读取用户的第一个输入的字母,读到回车符结束,而且它会把该字母转成大写。

// 如果用户输入的字母不存在于单词中
if (!researchLetter(letter, secretWord, letterFound))
{
  leftTimes--; // 将剩余猜测机会数减1
}

以上代码调用researchLetter函数在单词中查找用户输入的字母,如果没找到,则剩余猜测机会数扣除一次。

如果字母存在于单词中,则researchLetter函数还会更新letterFound数组(每个元素对应了神秘单词的每一个字母的猜测情况),将其中对应的0(还没猜到)改为1(已经猜到)。

这样,win函数在判断的时候,如果letterFound数组的每一个元素都为1,则返回1,表示本轮胜利,猜到单词的全部字母了。

暂时,while循环体的内容就到这里了,然后我们还要写跳出while循环之后的代码(或者胜利或者失败):

if (win(letterFound))
  printf("\n\n胜利了! 神秘单词是 : %s\n", secretWord);
else
  printf("\n\n失败了! 神秘单词是 : %s\n", secretWord);

游戏主体部分的代码就到这里了,给出我们到目前为止的完整程序:

#include <stdio.h>
#include <stdlib.h>
#include <ctype.h>

int win(int letterFound[]);
int researchLetter(char letter, char secretWord[], int letterFound[]);
char readCharacter();

int main(int argc, char* argv[])
{
  char letter = 0; // 存储用户输入的字母
  char secretWord[] = "BOTTLE"; // 要猜测的单词
  int letterFound[6] = {0}; // 布尔值的数组. 数组的每一个元素对应猜测单词的一个字母。 0 = 还没猜到此字母, 1 = 已猜到字母
  int leftTimes = 7; // 剩余猜测次数 (0 = 失败)
  int i = 0; // 为了遍历数组,需要一个下标

  printf("欢迎来到悬挂小人游戏!\n");

  while (leftTimes > 0 && !win(letterFound))
  {
    printf("\n\n您还剩 %d 次机会", leftTimes);
    printf("\n神秘单词是什么呢 ? ");

    /* 我们显示猜测的单词,将还没猜到的字母用*表示例如 : *O**LE */
    for (i = 0 ; i < 6 ; i++)
    {
      if (letterFound[i]) // 如果第i+1个字母已经猜到
        printf("%c", secretWord[i]); // 打印出来
      else
        printf("*"); // 还没猜到,打印一个*
    }

    printf("\n输入一个字母 : ");

    letter = readCharacter();

    // 如果用户输入的字母不存在于单词中
    if (!researchLetter(letter, secretWord, letterFound))
    {
      leftTimes--; // 将剩余猜测机会数减1
    }
  }

  if (win(letterFound))
    printf("\n\n胜利了! 神秘单词是 : %s\n", secretWord);
  else
    printf("\n\n失败了! 神秘单词是 : %s\n", secretWord);

  return 0;
}

int win(int letterFound[])
{
  int i = 0;
  int win = 1; // 1为胜利,0为失败

  for (i = 0 ; i < 6 ; i++)
  {
    if (letterFound[i] == 0)
      win = 0;
  }

  return win;
}

int researchLetter(char letter, char secretWord[], int letterFound[])
{
  int i = 0;
  int correctLetter = 0; // 0表示字母不在单词里,1表示字母在单词里

  // 遍历单词数组secretWord,以判断所猜字母是否在单词中
  for (i = 0 ; secretWord[i] != '\0' ; i++)
  {
    if (letter == secretWord[i]) // 如果字母在单词中
    {
      correctLetter = 1; // 表示猜对了一个字母
      letterFound[i] = 1; // 对于所有等于所猜字母的数组位置,都将其数值变为1
    }
  }

  return correctLetter;
}

char readCharacter()
{
  char character = 0;
  character = getchar(); // 读取一个字母

  character = toupper(character); // 把这个字母转成大写

  // 读取其他的字符,直到 \n (为了忽略它)
  while (getchar() != '\n')
    ;

  return character; // 返回读到的第一个字母
}

这一部分的程序,你可以将其存放在一个.c文件,例如叫hangman.c

然后用gcc编译(如果是在IDE里面,例如CodeBlocks,那直接点击编译运行):

gcc hangman.c -o hangman

运行:

./hangman

接下来我们要开始第二部分:

词库的代码

根据这部分的代码,我们还会接着修改和添加main函数的内容。

好吧,稍作休息,继续前进!

解方(2. 词库的代码)


我们已经编写了游戏主体部分的基本代码,但是我们的游戏目前还不能做到每轮随机抽取一个单词。

因此,接下来我们就带大家编写处理词库的代码。

首先,我们需要创建一个文件,用于存放所有的单词。

在Linux或Unix或Mac电脑下,我们都可以直接创建一个不带后缀名的文件,在Windows下可以创建.txt结尾的文本文件。

小编写这个游戏是在Linux系统下,所以直接用VIM或Emacs或其他编辑器创建一个文件, 位于我们源文件的相同目录下:

dictionary

在里面写入以下单词(每行一个,用回车符隔开):

YOU
MOTHER
LOVE
PANDA
BOTTLE
FUNNY
HONEY
LIKE
JAZZ
MUSIC
BREAD
APPLE
WATER
PEOPLE
DOG
CAT
GLASS
SKY
GOD
ZERO

当然了,我这里只是举个例子,你可以创建属于自己的词库。

新建两个文件


处理这个文件的代码将会不少(至少,我是这么预感的),因此,我们新建一个.c源文件,可以命名为dictionary.c

顺便,我们也创建dictionary.h这个头文件,其中存放dictionary.c中的函数的原型,这样我们在main函数里就可以通过

#include "dictionary.h"

来引入这些函数的定义了。

在dictionary.c中,首先我们引入一些头文件:

#include <stdio.h>
#include <stdlib.h>
#include <time.h> //我们需要这里面的随机数函数,还记得我们的第一个小游戏“或多或少”吗? 
#include <string.h> //我们需要strlen这个计算字符串长度的函数

#include "dictionary.h"

chooseWord函数


这个函数用于从文件dictionary中随机选取一个单词,此函数只有一个参数: 指向内存中可以写入单词的地址的指针,这个指针实参将由main函数提供。

函数返回值是int变量: 1表示一切顺利; 0表示出现错误。

此函数的开头是这样:

int chooseWord(char *wordChosen)
{
  FILE* dictionary = NULL; // 指向我们的文件dictionary(词库)的文件指针
  int wordNum = 0; // 词库中单词总数
  int chosenWordNum = 0; // 选中的单词编号
  int i = 0; // 下标
  int characterRead = 0; // 读入的字符
}

声明了一些变量,我们接着写:

dictionary = fopen("dictionary", "r"); // 以只读模式打开词库(dictionary文件)

if (dictionary == NULL) // 如果打开文件不成功
{
  printf("\n无法装载词库\n");

  return 0; // 返回0表示出错
}

这段代码不难吧,就是尝试打开词库(dictionary文件),并检测dictionary文件指针是否为NULL。

如果为NULL,表示打开失败。如果打开文件失败,则程序中止,因为没有进行下去的必要了。

// 统计词库中的单词总数,也就是统计回车符 \n 的数目
do
{
  characterRead = fgetc(dictionary);
  if (characterRead == '\n')
    wordNum++;
} while(characterRead != EOF);

上面这段代码中,我们借助fgetc函数遍历整个文件(一个字符一个字符读取)

我们统计读到的回车符(\n)的数目,每读到一个\n,我们对wordNum(单词总数)的值加1。

我们通过以上代码,就可以知道词库中的单词总数了,就是wordNum的值。

然后,我们需要一个函数,根据wordNum的值计算一个伪随机数出来,作为随机选取的单词编号,我们就来写一个函数,命名为: randomNum。

randomNum函数


此函数里的代码我们之前编写第一个C语言小游戏: “或多或少” 时已经用过了,就是简单的伪随机数生成。

作用: 用于返回一个介于 0~(单词总数-1) 之间的随机数。

int randomNum(int maxNum)
{
  srand(time(NULL));
  return (rand() % maxNum);
}

写好了randomNum函数,我们立即来使用它:

chosenWordNum = randomNum(wordNum); // 随机选取一个单词(编号)

接着,我们需要重新回到文件开始处来进行读取,为了回到文件开始处,可以调用函数 rewind。

// 我们重新从文件开始处读取(rewind函数),直到遇到选中的那个单词
rewind(dictionary);

while (chosenWordNum > 0)
{
  characterRead = fgetc(dictionary);
  if (characterRead == '\n')
    chosenWordNum--;
}

/* 文件指针已经指向正确位置,我们就用fgets来读取那一行(也就是那个选中的单词)*/
fgets(wordChosen, 100, dictionary);

// 放置 \0 字符用于表示字符串结束
wordChosen[strlen(wordChosen) - 1] = '\0';

fclose(dictionary);

return 1; // 一切顺利,返回1

dictionary.h文件


其中包含我们的dictionary.c中的函数原型,内容如下:

#ifndef DICTIONARY_H
#define DICTIONARY_H

int chooseWord(char *wordChosen);
int randomNum(int maxNum);

#endif

完整的dictionary.c文件

/*
悬挂小人游戏

dictionary.c
------------

这里定义了两个函数:

1. chooseWord 用于每轮从dictionary文件中随机抽取一个单词
2. randomNum 用于返回一个介于 0~(单词总数-1) 之间的随机数

*/
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <string.h>

#include "dictionary.h"

int chooseWord(char *wordChosen)
{
  FILE* dictionary = NULL; // 指向我们的文件dictionary的文件指针
  int wordNum = 0; // 单词总数
  int chosenWordNum = 0; // 选中的单词编号
  int i = 0; // 下标
  int characterRead = 0; // 读入的字符

  dictionary = fopen("dictionary", "r"); // 以只读模式打开词库(dictionary文件)
  if (dictionary == NULL) // 如果打开文件不成功
  {
    printf("\n无法装载词库\n");
    return 0; // 返回0表示出错
  }

  // 统计词库中的单词总数,也就是统计回车符 \n 的数目
  do
  {
    characterRead = fgetc(dictionary);
    if (characterRead == '\n')
      wordNum++;
  } while(characterRead != EOF);

  chosenWordNum = randomNum(wordNum); // 随机选取一个单词(编号)

  // 我们重新从文件开始处读取(rewind函数),直到遇到选中的那个单词
  rewind(dictionary);
  while (chosenWordNum > 0)
  {
    characterRead = fgetc(dictionary);
    if (characterRead == '\n')
      chosenWordNum--;
  }

   /* 文件指针已经指向正确位置,我们就用fgets来读取那一行(也就是那个选中的单词)*/
  fgets(wordChosen, 100, dictionary);

  // 放置 \0 字符用于表示字符串结束
  wordChosen[strlen(wordChosen) - 1] = '\0';
  fclose(dictionary);

  return 1; // 一切顺利,返回1
}

int randomNum(int maxNum)
{
  srand(time(NULL));
  return (rand() % maxNum);
}

修改hangman.c文件


现在,既然我们的处理词库的函数已经写完了,也就是在dictionary.c中,那么我们需要相应地修改我们的hangman.c文件中的main函数和其他几个子函数:

有了之前所有课程的知识,靠着注释,应该不难看懂。

完整的hangman.c文件

/*
悬挂小人游戏

main.c
------------

游戏的主体代码
*/

#include <stdio.h>
#include <stdlib.h>
#include <ctype.h>
#include <string.h>

#include "dictionary.h"

int win(int letterFound[], long wordSize);
int researchLetter(char letter, char secretWord[], int letterFound[]);
char readCharacter();

int main(int argc, char* argv[])
{
  char letter = 0; // 存储用户输入的字母
  char secretWord[100] = {0}; // 要猜测的单词
  int *letterFound = NULL; // 布尔值的数组. 数组的每一个元素对应猜测单词的一个字母。 0 = 还没猜到此字母, 1 = 已猜到字母
  int leftTimes = 7; // 剩余猜测次数 (0 = 失败)
  int i = 0; // 下标
  long wordSize = 0; // 单词的长度(字母数目)

  printf("欢迎来到悬挂小人游戏!\n");

  // 从词库(文件dictionary)中随机选取一个单词
  if (!chooseWord(secretWord))
    exit(0); // 退出游戏

 // 获取单词的长度
 wordSize = strlen(secretWord);

  letterFound = malloc(wordSize * sizeof(int)); // 动态分配数组的大小,因为我们一开始不知道单词长度
  if (letterFound == NULL)
    exit(0);

  // 初始化布尔值数组,都置为0,表示还没有字母被猜到
  for (i = 0 ; i < wordSize ; i++)
    letterFound[i] = 0;

  // 主while循环,如果还有猜测机会并且还没胜利,继续
  while (leftTimes > 0 && !win(letterFound, wordSize))
  {
    printf("\n\n您还剩 %d 次机会", leftTimes);
    printf("\n神秘单词是什么呢 ? ");

    /* 我们显示猜测的单词,将还没猜到的字母用*表示
        例如 : *O**LE */
    for (i = 0 ; i < wordSize ; i++)
    {
      if (letterFound[i]) // 如果第i+1个字母已经猜到
        printf("%c", secretWord[i]); // 打印出来
      else
        printf("*"); // 还没猜到,打印一个*
    }

    printf("\n输入一个字母 : ");
    letter = readCharacter();

    // 如果用户输入的字母不存在于单词中
    if (!researchLetter(letter, secretWord, letterFound))
    {
      leftTimes--; // 将剩余猜测机会数减1
    }
  }

  if (win(letterFound, wordSize))
    printf("\n\n胜利了! 神秘单词是 : %s\n", secretWord);
  else
    printf("\n\n失败了! 神秘单词是 : %s\n", secretWord);

  return 0;
}

// 判断是否胜利
int win(int letterFound[], long wordSize)
{
  int i = 0;
  int win = 1; // 1为胜利,0为失败

  for (i = 0 ; i < wordSize ; i++)
  {
    if (letterFound[i] == 0)
    win = 0;
  }

  return win;
}

// 在所要猜的单词中查找用户输入的字母
int researchLetter(char letter, char secretWord[], int letterFound[])
{
  int i = 0;
  int correctLetter = 0; // 0表示字母不在单词里,1表示字母在单词里

  // 遍历单词数组secretWord,以判断所猜字母是否在单词中
  for (i = 0 ; secretWord[i] != '\0' ; i++)
  {
    if (letter == secretWord[i]) // 如果字母在单词中
    {
      correctLetter = 1; // 表示猜对了一个字母
      letterFound[i] = 1; // 对于所有等于所猜字母的数组位置,都将其数值变为1
    }
  }

  return correctLetter;
}

char readCharacter()
{
  char character = 0;

  character = getchar(); // 读取一个字母
  character = toupper(character); // 把这个字母转成大写

  // 读取其他的字符,直到 \n (为了忽略它)
  while(getchar() != '\n')
    ;

  return character; // 返回读到的第一个字母
}

好了,这个小游戏已经写完了,用gcc编译并运行看看吧!

gcc dictionary.c hangman.c -o hangman

./hangman

优化建议


因为我们的项目是在Linux下用gcc来编译的,如果你是在Windows下用CodeBlocks等IDE来编译的,那么请将字典文件dictionary改成dictionary.txt

因为Windows的文件储存形式和Linux(或Unix)有些不一样。

改进游戏


  1. 目前来说,我们只让玩家玩一轮,如果能加一个循环,使得游戏每次询问玩家是否要再玩一次,那“真真是极好的”

  2. 目前还是单机模式,可以创建一个二人模式,就是一个玩家输入一个单词,第二个玩家来猜。

  3. 为什么不用printf函数来打印(绘制)一个悬挂小人呢?在每次我们猜错的时候,就把它画出来,每错一个,多画一笔,这样可以增加乐趣,可以用如下的代码:

if (猜错1个字母)
{
  printf(" _____\n");
  printf(" | |\n");
  printf(" | O\n");
  printf(" |\n");
  printf(" |\n");
  printf(" |\n");
  printf(" |\n");
  printf("__|__\n");
}
else if (猜错2个字母)
{
  printf(" _____\n");
  printf(" | |\n");
  printf(" | O\n");
  printf(" | |\n");
  printf(" |\n");
  printf(" |\n");
  printf(" |\n");
  printf("__|__\n");
}
else if (猜错3个字母)
{
  printf(" _____\n");
  printf(" | |\n");
  printf(" | O\n");
  printf(" | \\|\n");
  printf(" |\n");
  printf(" |\n");
  printf(" |\n");
  printf("__|__\n");
}
else if (猜错4个字母)
{
  printf(" _____\n");
  printf(" | |\n");
  printf(" | O\n");
  printf(" | \\|/\n");
  printf(" |\n");
  printf(" |\n");
  printf(" |\n");
  printf("__|__\n");
}
else if (猜错5个字母)
{
  printf(" _____\n");
  printf(" | |\n");
  printf(" | O\n");
  printf(" | \\|/\n");
  printf(" | |\n");
  printf(" |\n");
  printf(" |\n");
  printf("__|__\n");
}
else if (猜错6个字母)
{
  printf(" _____\n");
  printf(" | |\n");
  printf(" | O\n");
  printf(" | \\|/\n");
  printf(" | |\n");
  printf(" | /\n");
  printf(" |\n");
  printf("__|__\n");
}
else if (猜错7个字母)
{
  printf(" _____\n");
  printf(" | |\n");
  printf(" | O\n");
  printf(" | \\|/\n");
  printf(" | |\n");
  printf(" | / \\\n");
  printf(" |\n");
  printf("__|__\n");
}

上面代码中的空格也许不同平台的显示不一样,我这里是5个空格。可能需要大家自行调整。

如果7次机会全部用完,则小人挂掉,游戏结束。

请大家花点时间,好好理解这个游戏,并且尽可能地改进它。如果你可以不看我们的答案,而自己完成游戏和改进,那么你会收获很多的!

第二部分第十一课预告:


今天的课就到这里,一起加油吧。

下一课我们来做点复习性质的练习题,然后就开始第三部分[用C语言写图形界面的游戏]了。

C语言探索之旅 | 第二部分第十一课:练习题和习作