Files
fzu-product/2023旧版内容/3.编程思维体系构建/3.4.6.13.编写解析器.md

16 KiB
Raw Blame History

13.编写解析器

每个文本冒险都有一个解析器,但是解析器也有高下之分。一个简单的 "动词 - 名词 "解析器(就像我们从第二章开始一直使用的那个)对于一个精心设计的冒险游戏来说可能已经足够了。

然而Infocom 已经证明,一个更高级的解析器确实有助于制作一个令人愉快的游戏。它不一定要通过图灵测试。

记住,它只是一个游戏。但是解析器应该使玩家能够以一种或多或少的自然方式来表达他的意图。

我们目前的分析器主要由两行代码组成,隐藏在 parsexec.c 中。

char *verb = strtok(input, " \n");
char *noun = strtok(NULL, "\n");

好吧,我们再加上将动词映射到命令的 strcmp 调用序列,以及将名词映射到对象的 noun.c 中的函数,但仅此而已。在过去的 12 章中,这个系统为我们提供了良好的服务,但它也有缺陷。

  • 它只接受 "动词名词 "形式的简单命令;它不理解既有直接宾语又有间接宾语的句子,如把硬币放进盒子。
  • 它确实接受多字对象(如银币),但字与字之间的空格必须准确无误。我们的游戏拒绝银币和硬币之间的双空格。
  • 它是区分大小写的;"向北走 "的命令因为大写的 "G "而不被识别。

::: warning 🤔 思考题:你能想到有什么办法解决这些问题吗? :::

编写一个好的分析器并不是一件小事,但在这里我将给你一个相对简单的方法,我们将定义一个由模式列表组成的语法,类似于(但比)正则表达式要简单得多。

look around 仅仅是匹配而已。双空格、前导空格、尾部空格和大小写差异都被忽略了
go A 匹配单词 go 和对象的标签(见第 5 章和第 9 章关于 "标签 "的解释)。
put A in B 匹配单词 put 后面的标签,单词 in 和另一个标签。

为了解析用户的输入,我们将从上到下遍历模式列表,依次尝试将用户的输入与每个模式匹配。我们将在发现第一个匹配时停止。为了简单起见,我们将不使用回溯,尽管这可以在以后添加。

::: warning 🤔 思考题:如果我们使用回溯,那该怎么编写代码? :::

大写字母是我们语法中的非终端符号,它们可以匹配任何标签(任何对象)。当解析器在两个不同的标签(例如 "银币 "和 "银")之间进行选择时,较长的标签将被优先考虑。

然后,匹配的标签可以作为参数名词传递给其中一个执行函数。对于有一个以上名词的命令(在下一章介绍),参数传递变得有点不切实际。为了简单起见,我们将使用一个全局变量而不是参数(尽管全局变量的名声不好)。该变量将是一个字符串指针的数组。

const char *params[26];

该数组有 26 个元素;字母表中的每个(大写)字母都有一个,这足以满足一个模式中多达 26 个不同的非终端。对于一个(匹配的)模式中的每个非终端,将通过在非终端的数组元素中填充一个指向该特定标签的指针来 "捕获 "一个匹配的标签。params[0](第一个数组元素)捕获非终端 "A"params[1]捕获 "B",以此类推。一个简单的宏定义可以用来找到属于某个非终端的数组元素。

#define paramByLetter(letter)   (params + (letter) - 'A')

注意:你可能会发现数组长度为 26 有点过头了,但它确实让我们省去了写一些边界检查代码的麻烦,以防止在出现畸形模式时出现缓冲区溢出。

现在,我们必须想出一个办法来处理缺失的或不被承认的名词。

假设用户打错了字,输入了 "go kave"。问题是,这个命令到底应不应该匹配 "go A "这个命令?如果我们不想出解决办法,那么这个命令将无法匹配任何其他命令,并最终进入一个通用的错误处理程序,它可能会回答 "我不知道如何去 kave "这样的话。这就失去了改进这些 "消极反应 "的所有机会;回答 "我不知道你要去哪里 "已经感觉更自然了。最好的办法是将所有关于命令去向的答复保持在函数 executeGo 内。

::: warning 🤔 停下来想一想,可以怎么解决这个问题? :::

有几种方法可以实现这一点,但最简单的方法是允许非终止符匹配任何东西。所以不仅仅是一个有效的标签,也包括完全的胡言乱语、空白或什么都没有这种语句。这种 "无效 "的输入将被捕获为一个空字符串("")。

在模式中间有这样一个 "松散 "的非终端,确实会使模式匹配过程复杂化;在匹配输入 "把 foo 放在盒子里 "和模式 "把 A 放在 B 里 "时,它需要回溯以正确对齐 "in "这个词。

为了简单起见,我们将只对出现在模式最末端的非终止符进行松散匹配。为了能够正确匹配上面的例子,我们需要两个独立的模式:

  • 模式 "put A in B "将匹配有效的命令 put coin in box以及无效的命令 put coin in booox 和 put coin in。注意非终端 A 只匹配有效的标签(在这个例子中是硬币)。
  • 模式 "put A "匹配所有剩余的无效命令put coin, put koin, put koin in box, put koin in booox 和一个裸 put。还有最初的例子put foo in in box

模式的顺序在这里至关重要:如果 "放 A "在上面(意味着它将被首先尝试),那么它将消耗每个放的命令;一个有效的 "put coin in box "命令将被认为是无效的,因为没有 "硬币在盒子里 "的标签。

对于以多种形式出现的命令也是如此,比如说 look 这个命令:

  • Look around
  • look作为环顾四周的缩写
  • Look A

前两个命令形式可以为任何顺序,但第三个必须在最后。

::: warning 🤔 思考题:你是否有办法解决这个问题? :::

是时候将其付诸行动了。我们将抛弃模块 parsexec.c 的现有内容,用一个新的函数 parseAndExecute 的实现来取代它,该函数使用一个模式列表,应该能够匹配我们到目前为止实现的每一条命令。每个模式都与一个执行相应命令的函数相联系。

parsexec.c

extern bool parseAndExecute(const char *input);

parsexec.h

#include <ctype.h>
#include <stdbool.h>
#include <stdio.h>
#include "object.h"
#include "misc.h"
#include "match.h"
#include "location.h"
#include "inventory.h"
#include "openclose.h"

typedef struct
{
   const char *pattern;
   bool (*function)(void);
} COMMAND;

static bool executeQuit(void)
{
   return false;
}

static bool executeNoMatch(void)
{
   const char *src = *params;
   int len;
   for (len = 0; src[len] != '\0' && !isspace(src[len]); len++);
   //计算玩家输入的第一个字符串的长度
   if (len > 0) printf("I don't know how to '%.*s'.\n", len, src);
   return true;
}

bool parseAndExecute(const char *input)
{
   static const COMMAND commands[] =
   //模式数组
   {
      { "quit"                , executeQuit       },
      { "look"                , executeLookAround },
      { "look around"         , executeLookAround },
      { "look at A"           , executeLook       },
      { "look A"              , executeLook       },
      { "examine A"           , executeLook       },
      { "go to A"             , executeGo         },
      { "go A"                , executeGo         },
      { "get A"               , executeGet        },
      { "drop A"              , executeDrop       },
      { "ask A"               , executeAsk        },
      { "give A"              , executeGive       },
      { "inventory"           , executeInventory  },
      { "open A"              , executeOpen       },
      { "close A"             , executeClose      },
      { "lock A"              , executeLock       },
      { "unlock A"            , executeUnlock     },
      { "A"                   , executeNoMatch    }
   };
   const COMMAND *cmd;
   for (cmd = commands; !matchCommand(input, cmd->pattern); cmd++);
   return (*cmd->function)();
}

最难的部分是函数 matchCommand 的实现。但正如你在下面看到的,这也可以在不到 100 行的代码中完成。

match.h

#define MAX_PARAMS  26

extern const char *params[];

#define paramByLetter(letter)   (params + (letter) - 'A')

extern bool matchCommand(const char *src, const char *pattern);

match.c

#include <ctype.h>
#include <stdbool.h>
#include <string.h>
#include "object.h"
#include "misc.h"
#include "match.h"

const char *params[MAX_PARAMS];

static const char *skipSpaces(const char *src)
{
   while (isspace(*src)) src++;
   return src;
}

static const char *matchSpaces(const char *src)
{
   return *src == '\0' || isspace(*src) ? skipSpaces(src) : NULL;
}

static const char *matchTerminal(const char *src, char terminal)
{
   return terminal == ' '                    ? matchSpaces(src) :
          tolower(*src) == tolower(terminal) ? src + 1          : NULL;
}

static const char *matchTag(const char *src, const char *tag, bool atEnd)
{
   while (src != NULL && *tag != '\0')
   {
      src = matchTerminal(src, *tag++);
   }
   return atEnd && src != NULL && *skipSpaces(src) != '\0' ? NULL : src;
}

static const char *matchParam(const char *src, const char **par, bool loose)
{
   const char *restOfSrc = loose ? src + strlen(src) : NULL;
   OBJECT *obj;
   for (obj = objs; obj < endOfObjs; obj++)
   {
      const char **tag;
      for (tag = obj->tags; *tag != NULL; tag++)
      {
         const char *behindMatch = matchTag(src, *tag, loose);
         if (behindMatch != NULL && strlen(*tag) > strlen(*par))
         {
            *par = *tag;
            restOfSrc = behindMatch;
         }
      }
   }
   if (**par == '\0')
   {
      *par = src;
   }
   return restOfSrc;
}

bool matchCommand(const char *src, const char *pattern)
{
   const char **par;
   for (par = params; par < params + MAX_PARAMS; par++)
   {
      *par = "";
   }
   for (src = skipSpaces(src); src != NULL && *pattern != '\0'; pattern++)
   {
      src = isupper(*pattern)
               ? matchParam(src, paramByLetter(*pattern), pattern[1] == '\0')
               : matchTerminal(src, *pattern);
   }
   return src != NULL && *skipSpaces(src) == '\0';
}

我们调整各种命令的实现,以利用新的数组参数。

inventory.h

extern bool executeGet(void);
extern bool executeDrop(void);
extern bool executeAsk(void);
extern bool executeGive(void);
extern bool executeInventory(void);

inventory.c

#include <stdbool.h>
#include <stdio.h>
#include "object.h"
#include "misc.h"
#include "match.h"
#include "noun.h"
#include "move.h"

bool executeGet(void)
{
   OBJECT *obj = getVisible("what you want to get", params[0]);
   switch (getDistance(player, obj))
   {
   case distSelf:
      printf("You should not be doing that to yourself.\n");
      break;
   case distHeld:
      printf("You already have %s.\n", obj->description);
      break;
   case distOverthere:
      printf("Too far away, move closer please.\n");
      break;
   case distUnknownObject:
      // already handled by getVisible
      break;
   default:
      if (obj->location != NULL && obj->location->health > 0)
      {
         printf("You should ask %s nicely.\n", obj->location->description);
      }
      else
      {
         moveObject(obj, player);
      }
   }
   return true;
}

bool executeDrop(void)
{
   moveObject(getPossession(player, "drop", params[0]), player->location);
   return true;
}

bool executeAsk(void)
{
   moveObject(getPossession(actorHere(), "ask", params[0]), player);
   return true;
}

bool executeGive(void)
{
   moveObject(getPossession(player, "give", params[0]), actorHere());
   return true;
}

bool executeInventory(void)
{
   if (listObjectsAtLocation(player) == 0)
   {
      printf("You are empty-handed.\n");
   }
   return true;
}

我们在上一章中添加的模块也是如此。

opemclose.h

extern bool executeOpen(void);
extern bool executeClose(void);
extern bool executeLock(void);
extern bool executeUnlock(void);

openclose.c

#include <stdbool.h>
#include <stdio.h>
#include "object.h"
#include "match.h"
#include "reach.h"

bool executeOpen(void)
{
   OBJECT *obj = reachableObject("what you want to open", params[0]);
   if (obj != NULL) (*obj->open)();
   return true;
}

bool executeClose(void)
{
   OBJECT *obj = reachableObject("what you want to close", params[0]);
   if (obj != NULL) (*obj->close)();
   return true;
}

bool executeLock(void)
{
   OBJECT *obj = reachableObject("what you want to lock", params[0]);
   if (obj != NULL) (*obj->lock)();
   return true;
}

bool executeUnlock(void)
{
   OBJECT *obj = reachableObject("what you want to unlock", params[0]);
   if (obj != NULL) (*obj->unlock)();
   return true;
}

在 location.c 中look around 命令被赋予了自己的功能,与检查特定对象的 look 命令分开(见第 7-12 行)。

location.h

extern bool executeLookAround(void);
extern bool executeLook(void);
extern bool executeGo(void);

location.c

#include <stdbool.h>
#include <stdio.h>
#include "object.h"
#include "misc.h"
#include "match.h"
#include "noun.h"

bool executeLookAround(void)
{
   printf("You are in %s.\n", player->location->description);
   listObjectsAtLocation(player->location);
   return true;
}

bool executeLook(void)
{
   OBJECT *obj = getVisible("what you want to look at", params[0]);
   switch (getDistance(player, obj))
   {
   case distHereContained:
      printf("Hard to see, try to get it first.\n");
      break;
   case distOverthere:
      printf("Too far away, move closer please.\n");
      break;
   case distNotHere:
      printf("You don't see any %s here.\n", params[0]);
      break;
   case distUnknownObject:
      // already handled by getVisible
      break;
   default:
      printf("%s\n", obj->details);
      listObjectsAtLocation(obj);
   }
   return true;
}

static void movePlayer(OBJECT *passage)
{
   printf("%s\n", passage->textGo);
   if (passage->destination != NULL)
   {
      player->location = passage->destination;
      printf("\n");
      executeLookAround();
   }
}

bool executeGo(void)
{
   OBJECT *obj = getVisible("where you want to go", params[0]);
   switch (getDistance(player, obj))
   {
   case distOverthere:
      movePlayer(getPassage(player->location, obj));
      break;
   case distNotHere:
      printf("You don't see any %s here.\n", params[0]);
      break;
   case distUnknownObject:
      // already handled by getVisible
      break;
   default:
      movePlayer(obj);
   }
   return true;
}

我们的游戏仍然只接受简单的动词 - 名词命令,但新的解析器确实有可能接受有多个名词的命令,如 "put coin in box",这将在下一章中演示。

新的解析器比原来的解析器有了很大的改进,但以今天的标准来看,它仍然远远不够完美。例如,没有结构性的方法可以用一个命令操纵多个对象("获得硬币、钥匙和灯")或连续执行两个或多个命令("获得钥匙然后向东走")。这对于一个 RPG 游戏来说限制实在太大了!

在真正意义上,我们的分析器甚至不是一个分析器,它只是一个简单的模式匹配器。我们认为,对于一个简单的冒险游戏来说,它的工作已经足够好了。它的一些缺陷可以通过添加更多的模式而得到缓解。但最终,你会遇到它的局限性,你可能想转到更成熟的东西上。在这种情况下,我们推荐使用一个体面的分析器生成器(例如 Yacc。请注意这不是一件容易做到的事情。目前这已经超出了本教程的范围。 输出样例

Welcome to Little Cave Adventure. You are in an open field. You see: a silver coin a burly guard a cave entrance to the east dense forestall around

--> get coin You pick up a silver coin.

--> give coin You give a silver coin to a burly guard.

--> go cave You walk into the cave.

You are in a little cave. You see: an exit to the west solid rock all around a closed door to the south a tiny key

--> get key You pick up a tiny key.

--> go south The door is closed.

--> open door You open a closed door to the south.

--> go south You walk through the door into a backroom.

You are in a backroom. You see: solid rock all around an open door to the north a wooden box

--> unlock box You unlock a wooden box.

--> open box You open a wooden box.

--> examine box The box is open. You see: a gold coin

--> get gold You get a gold coin from a wooden box.

--> quit

Bye!