文本冒险游戏 - 如何区分一种项目类型与另一种项目类型以及如何构建项目类/子类

Text Adventure Game - How to Tell One Item Type from Another and How to Structure the Item Classes/Subclasses?

本文关键字:项目 类型 何构建 构建 子类 另一种 文本 何区 冒险游戏 一种      更新时间:2023-10-16

我是一个初学者程序员(他有很多与视频游戏设计相关的脚本经验,但编程经验很少 - 所以只是循环、流控制等基本的东西——尽管我确实有C++基础知识和C++数据结构和算法课程)。我正在做一个文本冒险个人项目(实际上,在我学习类如何工作之前,我很久以前就已经用 Python 编写了它 - 一切都是字典 - 所以这是可耻的)。我正在C++课上"重塑"它,以摆脱只做家庭作业的陈规。

我已经编写了我的播放器和房间类(这很简单,因为我只需要每个类一个)。我喜欢项目类(项目是房间中的任何物品,例如火炬、火、标志、容器等)。我不确定如何处理项目基类和派生类。这是我遇到的问题。

  1. 我如何以非狗屎的方式判断一个项目是否属于某种类型(很有可能我想多了)?

    例如,
    • 我设置了打印室信息功能,以便除了它可能执行的其他操作之外,它还打印其库存(即其内部)中每个对象的名称,并且我希望它打印容器对象的特殊内容(例如其库存的内容)。
    • 第一部分很简单,因为每个项目都有一个名称,因为 name 属性是基项类的一部分。但是,容器具有清单,这是容器子类独有的属性。
    • 我的理解是,基于对象的类类型执行条件逻辑是不好的形式(因为一个人的类应该是多态的),我假设(也许是错误的)将getHasInventory 访问器虚拟函数放在项目基类中是奇怪和错误的(我这里的假设是基于认为将虚拟函数放在基类中的每个派生类是疯狂的 - 我有大约十几个派生类 -其中几个是派生类的派生类)。
    • 如果这一切都是正确的,那么什么是可以接受的方法呢?一件明显的事情是将 itemType 属性添加到基中,然后执行条件逻辑,但这对我来说是错误的,因为它似乎只是检查类类型解决方案的重新蒙皮。我不确定上述假设是否正确,以及可能是一个好的解决方案。
  2. 我应该如何构建我的基类和派生类?

    • 我最初编写它们时,item 类是基类,大多数其他类使用单继承(除了一对具有多级的类)。
    • 这似乎带来了一些尴尬和重复我自己。例如,我想要一个标志和一个字母。标志是可读项目>不可取项目>项目。信件是可读物品>可取物品>物品。因为它们都使用单一继承,我需要两个不同的可读项目,一个是可获取的,另一个不是(我知道在这种情况下,我可以将可获取和不可获取变成基本属性,我做到了,但这可以作为一个例子,因为我仍然有类似的问题与其他类)。
    • 这对我来说似乎很恶心,所以我又试了一下,并使用多重继承和虚拟继承来实现它们。就我而言,这似乎更灵活,因为我可以编写多个类的类并为我的类创建一种组件系统。
    • 这些方法中的一种比另一种更好吗?有没有第三种更好的方法?

解决问题的一种可能方法是多态性。通过使用多态性,您可以(例如)拥有单个describe函数,该函数在调用时会导致项目向玩家描述自身。您可以对use和其他常见动词执行相同的操作。


另一种方法是实现一个更高级的输入解析器,它可以识别对象并将动词传递给项目的某些(多态)函数,以便自己处理。例如,每个项目都可以有一个返回可用谓词列表的函数,以及一个返回项目"名称"列表的函数:

struct item
{
// Return a list of verbs this item reacts to
virtual std::vector<std::string> get_verbs() = 0;
// Return a list of name aliases for this item
virtual std::vector<std::string> get_names() = 0;
// Describe this items to the player
virtual void describe(player*) = 0;
// Perform a specific verb, input is the full input line
virtual void perform_verb(std::string verb, std::string input) = 0;
};
class base_torch : public item
{
public:
std::vector<std::string> get_verbs() override
{
return { "light", "extinguish" };
}
// Return true if the torch is lit, false otherwise
bool is_lit();
void perform_verb(std::string verb, std::string) override
{
if (verb == "light")
{
// TODO: Make the torch "lit"
}
else
{
// TODO: Make the torch "extinguished"
}
}
};
class long_brown_torch : public base_torch
{
std::vector<std::string> get_names() override
{
return { "long brown torch", "long torch", "brown torch", "torch" };
}
void describe(player* p) override
{
p->write("This is a long brown torch.");
if (is_lit())
p->write("The torch is burning.");
}
};

然后,如果玩家输入例如light brown torch解析器会查看所有可用物品(玩家物品栏中的物品,然后是房间中的物品),获取每个物品名称列表(调用物品get_names()函数)并将其与brown torch进行比较。如果找到匹配项,解析器调用项目perform_verb传递相应参数(item->perform_verb("light", "light brown torch")的函数)。

您甚至可以修改解析器(和项目)以单独处理形容词,甚至是像the这样的文章,或者保存上次使用的项目,以便可以使用it引用它。

构建不同的房间和物品是乏味的,但一旦完成了一个好的设计,仍然微不足道(你真的应该花一些时间创建需求,分析需求,并创建一个设计)。真正困难的部分是编写一个像样的解析器。


请注意,这只是在此类游戏中处理项目和动词的两种可能方法。还有许多其他方法,对许多人来说,将它们全部列出。

您正在问一些很好的问题,例如如何设计,构建和实现程序,以及如何对问题域进行建模。

OOP,"方法"和途径

您提出的问题表明您已经了解了OOP(面向对象编程)。在很多关于OOP的介绍性材料中,通常鼓励直接通过对象对问题域进行建模,并通过向对象添加方法来子类型化和实现功能。一个经典的例子是建模动物,例如一个Animal类型和两个子类型,DuckCat,以及实现功能,例如walkquackmew

直接使用对象和子类型对问题域进行建模是有意义的,但与简单地使用具有不同字段描述问题的单个或几个类型相比,它也可能是矫枉过正和麻烦的。在您的情况下,我确实相信像您对对象和子类型或替代方法一样更复杂的建模是有意义的,因为除其他方面外,您还具有因类型而异的功能以及一些复杂的数据(例如带有库存的容器)。但要记住的是 - 有不同的权衡,有时,使用具有多个不同字段的单个类型来对域进行建模总体上更有意义。

通过基类和子类型上的方法实现所需的功能同样具有不同的权衡,对于给定的情况,这并不总是一个好方法。对于您的一个问题,您可以执行一些操作,例如添加print方法或类似于基类型和每个子类型,但这在实践中并不总是那么好(一个简单的示例是计算器应用程序,其中简化用户输入的算术表达式(如(3*x)*4/2)如果使用将方法添加到基类的方法,则实现起来可能会很麻烦)。

替代方法 - 标记并集/总和类型

有一个非常好的基本抽象称为"标记联合"(它也被称为"不相交联合"和"总和类型")。关于标记联合的主要思想是,您有一个由几组不同的实例组成的联合,其中给定实例属于哪个集合很重要。它们是C++称为enum中功能的超集。遗憾的是,C++目前不支持标记联合,尽管有研究(例如 https://www.stroustrup.com/OpenPatternMatching.pdf,尽管如果您是初学者程序员,这可能有点超出您的范围)。据我所知,这与您在这里给出的示例非常吻合。Scala中的一个例子是(许多其他语言也支持标记联合,例如Rust,Kotlin,Typescript,ML语言,Haskell等):

sealed trait Item {
val name: String
}
case class Book(val name: String) extends Item
case object Fire extends Item {
val name = "Fire"
}
case class Container(val name: String, val inventory: List[Item]) extends Item

据我所知,这很好地描述了您不同类型的项目。请注意,Scala在这方面有点特别,因为它通过子类型实现了标记联合。

如果您随后想要实现某些打印功能,则可以使用"模式匹配"来匹配您拥有的项目并执行特定于该项目的功能。在支持模式匹配的语言中,这是方便且不脆弱的,因为模式匹配会检查您是否已经涵盖了每种可能的情况(类似于C++中的switch,而不是枚举检查您是否涵盖了每种可能的情况)。例如在 Scala 中:

def getDescription(item: Item): String = {
item match {
case Book(_) | Fire => item.name
case Container(name, inventory) =>
name + " contains: (" +
inventory
.map(getDescription(_))
.mkString(", ") +
")"
}
}
val description = getDescription(
Container("Bag", List(Book("On Spelunking"), Fire))
)
println(description)

您可以将两个代码片段复制粘贴到此处并尝试运行它们:https://scalafiddle.io/.

这种建模与所谓的"数据类型"非常有效,在类本身中没有功能或功能很少,并且类中的字段基本上是其接口的一部分("接口"是指您希望更改使用这些类型的实现,如果您曾经添加到, 删除或更改类型的字段)。

相反,我发现当类内部的实现不是其接口的一部分时,更传统的子类型建模和方法更方便,例如,如果我有一个描述碰撞系统接口的基本类型,并且它的每个子类型都有不同的性能特征,方便不同的情况。隐藏和保护实现,因为它不是接口的一部分,这很有意义,并且非常适合所谓的"迷你模块"。

在C++(和C)中,尽管缺乏语言支持,但有时人们确实会以各种方式使用标记的联合。我看到在 C 中使用的一种方法是制作 C 联合(尽管要小心内存和语义等方面),其中使用enum标签来区分不同的情况。这很容易出错,因为您可能很容易最终访问一种enum情况下的字段,而该字段对该enum情况无效。

您还可以将命令输入建模为标记的联合。也就是说,解析可能有些挑战性,如果您是初学者程序员,解析库可能会有点复杂;保持解析简单可能是个好主意。

旁注

C++是一种特殊的语言 - 我不太喜欢它,因为我不太关心资源使用或运行时性能等,因为出于多种不同的原因,因为它可能很烦人并且开发起来不那么灵活。而且发展起来可能具有挑战性,因为您必须始终非常小心地避免未定义的行为。也就是说,如果资源使用或运行时性能确实很重要,那么根据具体情况,C++可能是一个非常好的选择。在C++语言及其社区中也有许多非常有用和重要的见解,例如RAII,所有权和生命周期。我的建议是,学习C++是个好主意,但你也应该学习其他语言,例如静态类型的函数式编程语言。FP(函数式编程)和支持FP的语言有许多优点和缺点,但它们的一些优点非常非常好,特别是reg.不变性和副作用。

在这些语言中,Rust 在某些方面可能是最接近C++的,尽管我没有 Rust 的经验,因此不能保证该语言或其社区。

作为旁注,您可能对这个维基百科页面感兴趣:https://en.wikipedia.org/wiki/Expression_problem 。