65.9K
CodeProject 正在变化。 阅读更多。
Home

Borg 知识吸收器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.73/5 (16投票s)

2005年7月24日

13分钟阅读

viewsIcon

82708

downloadIcon

583

一个简单的游戏,它会累积事实,并通过与玩家进行是或否问题的对话来学习。

Image 1

背景

不久前,一个以《星球大战》为主题的 Flash 游戏在博客和电子邮件中流传,让玩家可以尝试难倒达斯·维达。它甚至在 CodeProject 这里进行了讨论。

游戏的 premise 是达斯会让你想一个东西,然后通过问你“是”、“否”、“也许”、“有时”等问题来猜出你所想的东西。游戏的乐趣一部分在于听到达斯侮辱你,看到结尾跳舞的风暴兵,以及其他 UI 花絮,但乐趣的另一部分也在于看着达斯通过问一些有时看起来非常有见地的(问题)来缩小你所想的词的范围。

玩了一会儿游戏后,我意识到它只是在遍历一个由问题和“事物”组成的树状结构。我被这个游戏与我 80 年代初在 TRS-80 彩色电脑上玩的一个游戏(显然没有 Flash 界面)的相似之处所打动。那时,像 Eliza 这样的伪人工智能程序的 PC 实现非常流行,而决策树被认为是实现有限人工智能的一种可能方法。那个程序(可惜我记不起名字了)会问你“是”和“否”的问题,并且似乎能从经验中学习。那时我们还年轻,很容易被惊艳到。

当然,这全部都是字符串操作,但却是 *引人入胜* 的字符串操作。显然,现在仍然如此。看到办公室里的人反复玩那个 Sith 游戏后,我开始对用 C# 和 XML 重新实现那个最初的游戏感兴趣,使用了大家最喜欢的另一个事实饥渴的恶棍——Borg。在这篇文章中,我将向你展示如何编写一个模仿 Sith 游戏核心功能的游戏,并讨论控制台应用程序、XML 序列化以及那些美好的旧时光……

算法

在深入代码之前,我认为通过一个示例运行来讨论会很有帮助。我将使用与图 1 中所示相同的数据。您可以区分用户输入和程序输出,因为用户输入总是显示为亮绿色。

当应用程序运行时,它会提示用户想一个名词。在此示例中,用户将想到一只兔子。该应用程序会提出它的唯一问题,并要求用户给出是或否的回答。在我提供的源代码数据库中,初始问题是“它是否活着(y/n)”。(想到兔子)的回答是“是”,所以用户输入“y”。应用程序会沿着“是”的分支遍历树,并读取初始问题“是”分支上的名词(乌龟)。如果“是”节点是另一个 Question 对象,游戏将只询问该问题文本,并继续遍历问题的“Yes”和“No”属性,直到找到一个 Noun 对象。在此示例中,它已经到达了决策树这个分支的末端,并询问用户“乌龟”是否是他们想的名词。

答案将是“否”,因为我们在想“兔子”。现在应用程序切换到知识获取逻辑。该应用程序会询问用户他们所想的名词。用户输入“rabbit”。然后应用程序会征求一个问题,该问题可以区分用户有的名词(兔子)和程序期望的名词(乌龟)。用户可以输入诸如“它是一种爬行动物吗?”之类的问题。

然后,应用程序会将新的知识保存回原始数据文件,将其内部指针重置回根 Question 对象,并询问用户是否想再次玩。XML 数据文件是可移植的,可以发送给他人玩。

言语廉价,有时还令人困惑。如果您想了解对象模型(以及描述该对象模型的持久化 XML)如何通过这个简单的例子演变,可以参考图 2。

Image 2

代码

对象模型中有两个基本构造:Question 对象和 Noun 对象。Noun 对象只有一个 Text 属性。Question 对象有一个 Text 属性,以及根据用户对 QuestionText 属性的响应而遵循的 YesNo 属性。Question 对象的 YesNo 属性可以是其他 Question 对象,也可以是 Noun 对象。由于节点可以是两种不同类型之一,因此我创建了一个接口,QuestionNoun 对象都实现该接口,称为 IBorgElement,并将其用作 YesNo Question 属性的类型。我将 QuestionNoun 类型的共同元素合并到接口中(一个字符串 Text 属性和一个 Serialize 方法),但这不是必须的。

using System;
using System.Xml;

namespace Borg {
   public interface IBorgElement {
      string   Text {get;}
      void     Serialize(XmlDocument doc, XmlNode node);
   }
}

Noun 对象

Noun 对象非常直接。它可以带一个 XML 节点进行实例化,以帮助反序列化,也可以只带字符串进行实例化,用于 Text 属性。

public Noun(string text) {
   _text = text;
}

public Noun(XmlNode node) {
   XmlAttribute   text     = node.Attributes[Noun.TEXT_ATTRIBUTE_NAME];

   if (text != null)
      _text = text.InnerText;
}

Noun.TEXT_ATTRIBUTE_NAME 只是一个私有的常量字符串,用于描述 XML 文档中 Noun 节点中 Text 属性的名称。在这种情况下,它是“Text”。我不喜欢让“Text”字面量散布在类中进行序列化和反序列化,因此我将其设置为私有常量。您也会在 Question 类中看到这一点。

Noun 类中唯一有趣的方面是序列化代码。NounQuestion 对象必须在每次向对象模型添加新事实后序列化到 XML 文档中。序列化的 Noun 对象非常简单,在 XML 文件中看起来像这样

<Noun Text="turtle" />

它们在文档中的位置决定了它们与其他名词的关系。序列化对象状态的代码如下所示

void Borg.IBorgElement.Serialize(XmlDocument doc, XmlNode node) {
   XmlNode        noun  = doc.CreateNode(XmlNodeType.Element, 
                                 Noun.NodeName, string.Empty);
   XmlAttribute   text  = doc.CreateAttribute(string.Empty, 
                                 Noun.TEXT_ATTRIBUTE_NAME, string.Empty);

   text.InnerText = _text;   

   // Add the attribute to the new Noun node.
   noun.Attributes.Append(text);
   // Add the Noun node to it's place in the master document.
   node.AppendChild(noun);
}

如果 YesNo 属性是 Noun 对象,那么 Question 对象在序列化时会调用此代码。我将在下面的部分展示这一点。该方法需要对主 XmlDocument 对象的引用,以便创建新节点和属性,并使用 XmlNode 参数来了解在文档中的哪个位置添加自身。Noun.NodeName 是一个公共的、静态的类属性,它返回 Noun 的 XML 节点名称。它是公共的,以便其他类也能知道 Noun 节点的确切名称。

Question 对象

由于它们实现了与 Noun 对象相同的接口,因此它们有一些相似之处。它们有一个字符串 Text 属性,并且可以使用传递给 Noun 对象相同的参数进行序列化。它们可以通过 XML 节点或通过属性值进行构造。然而,Question 对象与 Noun 对象不同之处在于,它们具有 IBorgElement 类型的 YesNo 属性。

public   IBorgElement      Yes {
   get {return _yes;}
   set {_yes = value;}
}

public   IBorgElement      No {
   get {return _no;}
   set {_no = value;}
}

这些属性由 App 类使用,以遍历决策树,根据用户输入,无论是下一个问题还是名词。

App 类

QuestionNoun 对象只是智能的数据容器,具有有限的活动功能。App 类负责玩游戏。

它首先验证输入,并对您想使用的数据库(Borg 术语中的“集体”)做出一些假设。它加载文件,并将第一个问题(根节点下的第一个 XmlNode)传递给根 Question 的构造函数。该构造函数解析 Question 节点,并根据节点名称将 YesNo XmlNodes 传递给 QuestionNoun 构造函数。问题被递归地构造,直到 Noun XML 节点结束新对象的创建。

层次结构完全构建后,App 类会询问第一个问题。它将控制权传递给一个函数,该函数会等待用户输入“Y”或“N”来征求答案。

private static YesNoAnswer SolicitAnswer(string question, 
                                    ConsoleEx.Colour colour) {
   string answer  = string.Empty;

   // Keep prompting until the user presses "y" or "n"
   do {
      ConsoleEx.Write(question + " (y/n) ", colour);
      answer = ConsoleEx.ReadLine(ConsoleEx.Colour.Green); // User input in green.
   } while (answer.ToLower() != "n" && answer.ToLower() != "y");

   return (answer == "y" ? YesNoAnswer.Yes : YesNoAnswer.No);
}

问题回答后,App 会检查 Yes 或 No IBorgElement 对象的类型。如果它是 Question 对象,它会将当前指针设置为新的 Question 对象,并重新开始该过程。如果 IBorgElement 对象是 Noun 对象,它将进入一套新的逻辑。

App 类会询问用户他们想的名词是否是最终对象中的 Noun。如果是,游戏会自我表扬并检查用户是否想再次玩。如果名词猜测不正确,应用程序会询问用户选择的名词。然后,它会征求一个问题,该问题对旧名词的回答是“是”,对新名词的回答是“否”。这个问题是经过深思熟虑才征求的,因为应用程序会将旧名词放在新问题的“否”侧,将新名词放在新问题的“是”侧。如果新问题措辞不当,下次遇到该问题时,答案就会被调换。

一旦 App 类知道了旧名词和新名词以及区分它们的那个问题,它就会创建一个新的 Question 对象,并将 Yes 和 No IBorgElement 节点分别设置为新旧名词。它将新的 Question 对象放回对象层次结构中旧名词所在的位置,并持久化数据。如果您愿意,可以参考图 2 以获得此图形表示。

数据通过创建一个新的 XmlDocument 对象(带有虚拟根节点“Database”节点)并将其传递给根 Question 对象序列化例程来持久化。

private static void SaveDatabase(IBorgElement data, string fileName) {
   XmlDocument doc = new XmlDocument();

   // Initialize the Xml document with the root node.
   doc.AppendChild(doc.CreateNode(XmlNodeType.Element, 
                   App.ROOT_NODE_NAME, string.Empty));

   data.Serialize(doc, doc.SelectSingleNode(App.ROOT_NODE_NAME));
   doc.Save(fileName);
}

App.ROOT_NODE_NAME 是根节点的名称,“Database”。Question 对象将其自身序列化到根节点的新 XmlDocument 中,并开始序列化 YesNo 属性。这将强制层次结构中的所有对象在主文档中创建自己的 XmlNode,并提供新问题和答案的知识树的完整表示。

void Borg.IBorgElement.Serialize(XmlDocument doc, XmlNode node) {
   XmlNode        question = doc.CreateNode(XmlNodeType.Element, 
                               Question.NodeName, string.Empty);
   XmlAttribute   text     = doc.CreateAttribute(string.Empty, 
                               Question.TEXT_ATTRIBUTE_NAME, string.Empty);

   text.InnerText = _text;
   // Append the Text attribute to the new Question node.
   question.Attributes.Append(text);

   XmlNode  yesNode  = doc.CreateNode(XmlNodeType.Element, 
                           Question.YES_NODE_NAME, string.Empty);
   XmlNode  noNode   = doc.CreateNode(XmlNodeType.Element, 
                           Question.NO_NODE_NAME,  string.Empty);

   _yes.Serialize(doc, yesNode);    // Serialize whatever's on the "Yes" side.
   _no.Serialize(doc, noNode);      // Serialize whatever's on the "No" side.

   question.AppendChild(yesNode);   // Append the Yes node to the Question node
   question.AppendChild(noNode);    // Append the No node to the Question node

   // Append the Question node to where
   // it goes in the master document.
   node.AppendChild(question);
}

生成的 XmlDocument 会覆盖旧文件,游戏会询问用户是否想再次玩。

窗口装饰

我意识到并非每个人都想从我的根问题(“它是否活着”)和我的超级有创意的名词开始。我也意识到创建初始 XML 文档作为数据库是一项乏味且容易出错的过程。App 类允许您通过运行带有某些命令行开关的应用程序来创建自己的种子数据库。如果您使用四个参数运行,并且这些参数是 /db:/question:/yes:/no:,则可以创建自己的根数据库。参数的顺序无关紧要。

borg /db:"c:\birds.xml" /question:"Does it fly" /yes:"sparrow" /no:"penguin"

该命令将在 *c:\* 的根目录中创建一个名为 *birds.xml* 的新数据库,并使用指定的初始问题和名词进行种子填充,并在创建成功后打开它运行。抱歉,例子中有动物的偏见,但我的学位是动物学……

结论

您可以看到 Sith 游戏利用了类似的引擎,但在每个问题中可以有更多的分支方向。Sith 游戏允许有多种选择,而不是“是”和“否”。像我在这里所做的那样将选择限制在“是”和“否”可以满足我逻辑上、二元的一面,并产生较少的数据关系歧义。并非巧合,它也更容易编写。

关注点

当年...

在互联网、USB 和软盘出现之前,在只有富家子弟拥有 300 波特调制解调器(当恐龙统治地球时)的年代,书呆子们对应用程序共享机制的访问非常有限。我不得不从一本杂志上输入那个原始游戏的源代码,然后才能将其保存到我的磁带驱动器。那时你真的必须选择有趣的项目才能输入它们,因为敲出应用程序的源代码并调试不可避免的错字是一项巨大的时间投资。这大概就是我清楚地记得玩这个游戏的原因。全部在一个 32X16 的屏幕上。美好的时光……

控制台颜色

我最近一直在写控制台应用程序,并希望为自己和他人编写体面的控制台界面变得更容易。我在这里添加了一些代码来实现彩色输出,并且正在编写一个全面的控制台操作库,该库应该在 2-3 周内在 CodeProject 上发布。该项目中的部分代码包含在 ConsoleEx 类中。

序列化

我编写的每个对象都知道如何自己序列化和反序列化。我知道很多人讨厌这样做,宁愿用 [Serializable] 属性来修饰类,然后让框架来处理。我曾尝试使用框架自动化序列化和反序列化足够多次,以至于我开始讨厌它了。为了额外的代码,您可以获得极大的处理灵活性,所以我自己创建和解析 XML 文档。在处理 XML 节点和属性等内容时,不要在您的 [反]序列化代码中变得粗心,然后开始将对象当作字符串数据来处理。只使用真正的 XML 对象,这样就不会被某个用户将尖括号和与号插入到他们的数据中而困扰。

此外,如果解析的文档中存在错误,您可以抛出非常准确的、上下文相关的异常,以帮助人们诊断输入文件问题。

使用 App.Config

将运行时设置放入 App.Config 是一个众所周知的快捷方式,它允许您将少量的 XML 解析减少到更少量的代码,并像这样读取您的设置:

fileName = 
  System.Configuration.ConfigurationSettings.AppSettings.Get("LastDBPath");

显然,它不应该用于重写相同的设置。网上有很多人强烈反对这样做,因为应用程序文件夹(App.Config 所在的位置)出于安全原因可能不被应用程序写入。框架也反对这种概念,因为它允许您读取但不能写入此文件。

我特意在此项目中编写了 XML 解析代码来在运行时更新 App.Config,这违背了最佳实践。我想保存最后成功加载的数据库的路径,这样最终用户就不必每次都在命令行上指定路径。他们只需要在更改数据库时指定。

如果您想在程序执行之间保存少量数据,您应该怎么做?注册表,我想,但它也充满了自身的安全隐患。INI 文件?我不会回去用那个。XCOPY 部署怎么了?如果将配置文件复制到整个硬盘驱动器,就无法进行 XCOPY 部署……

新的控制台模式

我喜欢在编写控制台应用程序时使用的一种策略是将大段的样板文本放入可执行文件中的嵌入资源中,并在需要显示时在运行时将其流式输出。此技术的好候选者是命令行开关参数和有关应用程序运行时操作的说明,就像我在本项目中所做的那样。文件 CLIText.txtInstructions.txt 都嵌入到可执行文件中,并通过调用以下函数在需要时显示:

private static string ReadResource(string resource) {
   Assembly       me    = Assembly.GetExecutingAssembly();
   string         res   = me.GetName().Name + "." + resource;
   StreamReader   sr    = new StreamReader(me.GetManifestResourceStream(res));
   string         data  = sr.ReadToEnd();
   sr.Close();

   return data;
}

这样做可以避免在 Main() 方法开头出现大量混乱且难以编辑的 System.Console.WriteLine() 调用。

构建后

只是为了警告,该项目使用构建后步骤将 Borg.xml 文件从项目文件夹复制到运行时文件夹。因此,如果您在 IDE 中反复运行该应用程序,您添加到数据库中的任何事实都将在下次编译时被覆盖。

鸣笛

最后但同样重要的是,为了解析这里的命令行参数,我使用了我的 Yet Another Command Line Argument Parser (YACLAP) 库,您可以在 这里 阅读相关信息。

修订历史

  • 2005 年 7 月 25 日 - 初始修订。
© . All rights reserved.