在Godot中创建文本冒险游戏:步骤、代码和特性

在Godot中创建文本冒险游戏

注意:这篇文章发布已超过两年,因此其中包含的信息可能已过时。如果您发现了过时内容,请留下评论,我公司将尽力纠正。

2023年11月26日 - 阅读时长40分钟

Godot是一款出色的游戏引擎,我一直想找个项目来加深对这个平台的了解。在试着绘制图形、熟悉界面之后,我决定创建一个文本冒险游戏。

早期,由于硬件限制,图形冒险游戏难以实现,文本冒险游戏因此十分流行。我记得在80年代末玩过几款冒险游戏,90年代还玩过一些简单的多人地下城游戏(MUDs),所以文本冒险游戏对我来说带着一丝怀旧的气息。

我选择创建文本冒险游戏,是因为大部分代码应该比较简单。这里不需要进行碰撞检测或路径查找,只需要一个文本解析器和一些输出就行。

在本文中,我将介绍创建一个(非常简单的)文本冒险游戏所需的步骤。该游戏允许玩家在多个房间中导航,并与锁和钥匙进行交互。由于本文示例中可能有大量代码,我会用“...”缩写部分代码,表明部分代码已被省略。

如果您想查看所有代码(甚至运行游戏),我公司已将其上传到了GitHub仓库。

一、创建控制界面

我们要做的第一件事是添加游戏运行所需的所有元素。基本上,我们需要一个供用户输入文本的区域,以及一个用于显示游戏中情况的输出区域。

为了实现游戏界面,我在游戏场景中添加了以下节点。

  • Control - 游戏的主容器。
    • Background - 一个背景容器,填充游戏应用窗口。
      • ColorRect - 一个简单的矩形,为游戏提供背景颜色。
      • MarginContainer - 这个容器为游戏的内部元素提供了一个包装和边距。
        • Rows - 一个用于组织游戏元素结构的容器,确保它们以行的形式显示。
          • GameText - 一个RichTextLabel,用于更新输出文本。
          • PanelContainer - 标签和单行文本编辑元素的容器。
            • Label - 包含文本“>”,作为文本输入的光标。
            • LineEdit - 用户输入命令的文本字段。

完成后,界面大致如下。

主要的主题样式被应用到了像ColorRect和PanelContainer中的Label这样的元素上。我希望营造出一种绿屏效果,即文本显示为浅绿色,背景为深绿色,模仿老式单色CRT屏幕的感觉。Label元素用于显示“>”字符,作为“在此输入”的指示。对于LineEdit组件,我还开启了光标闪烁功能,以进一步提示用户可以在此处输入。

现在运行游戏,应该能看到创建好的界面。您可以随意调整界面的外观和感觉,以达到您想要的效果。

现在我们有了界面,接下来为其添加一些交互功能。

二、添加交互

为了监听用户提交文本的操作,我们需要将text_submitted信号添加到LineEdit节点上。当用户在键盘上按下回车键时,该节点会发出这个信号,这为我们响应界面中输入的“命令”提供了一种简洁的方式。

您可以在场景窗口中点击LineEdit节点,然后从屏幕右侧的界面面板中选择“Node”,这样就能看到该节点可以发出的信号列表。

双击“text_submitted”信号,然后点击“Connect”,将该信号添加到节点上,并在LineEdit类中创建所需的代码。

一开始我们会得到一个空函数,该函数有一个参数,即用户提交的文本。我们稍后会为这个函数添加更多内容,但现在只需要添加一个条件语句,如果用户没有输入任何文本,则不做任何操作。如果用户输入了一些文本,我们就清空LineEdit节点,并对输入的文本进行处理。

  
extends LineEdit

func _on_text_submitted(new_text):
  if (new_text.is_empty()):
    return

  # 清空文本区域的文本。
  self.set_text('')

  # 对“new_text”变量进行处理,并处理输出结果。
  

此时,我们还要添加一个_read()函数,该函数会在该节点首次进入场景树时被调用。这个函数只是调用当前节点的grab_focus()函数,这会将光标置于LineEdit节点中,以便用户开始输入命令。

  
extends LineEdit

# 节点首次进入场景树时调用。
func _ready():
  self.grab_focus()

func _on_text_submitted(new_text):
  if (new_text.is_empty()):
    return

  # 清空文本区域的文本。
  self.set_text('')

  # 对“new_text”变量进行处理,并处理输出结果。
  

交互功能就添加到这里,接下来我们对用户的输入进行处理。

三、处理输入

让这个游戏正常运行的关键在于将用户输入的内容转换为有意义的命令。我们不希望用户输入一些文本后导致游戏出错或出现意外情况,因此需要一套严格的命令匹配规则,只有符合这些规则的命令才会被接受。

为了实现这一点,我们创建了一个名为InstructionSet的类,其中包含了游戏可能拥有的所有命令。对于游戏中的基本移动操作,我们有“north”(北)、“south”(南)、“east”(东)和“west”(西),另外还有一个“not found”,以防无法将文本转换为指令。

  
class_name InstructionSet

const NOT_FOUND = "not_found"

const NORTH = "north"

const SOUTH = "south"

const EAST = "east"

const WEST = "west"
  

然后,我们创建另一个名为TextParser的类,用于将用户输入的文本转换为指令。这个类有一个名为parse()的函数,它接受我们要解析的文本,并返回一个指令。parse函数在文本到指令的解释上允许一定的变化,例如“north”和“go north”都会返回“NORTH”指令。

  
class_name TextParser

var InstructionSet = load("res://src/InstructionSet.gd")

# 将给定的输入字符串解析为指令。
func parse(text):
  match text:
    'go north':
      return InstructionSet.NORTH
    'north':
      return InstructionSet.NORTH
    'go south':
      return InstructionSet.SOUTH
    'south':
      return InstructionSet.SOUTH
    'go east':
      return InstructionSet.EAST
    'east':
      return InstructionSet.EAST
    'go west':
      return InstructionSet.WEST
    'west':
      return InstructionSet.WEST

  return InstructionSet.NOT_FOUND

func get_object():
  return object
  

通过这种方式解析文本,我们将用户输入的文本和最终产生的指令完全分离。

在TextParser类中,我们不对指令进行处理,这是另一个名为GameDataProcessor的类的工作。这个类在游戏开始时会将房间数据加载到内存中,并将输入的指令转换为改变应用程序状态的操作。根据用户的操作,我们可以将房间信息渲染为字符串,并将这些文本返回给用户。

该类的rooms属性包含了从JSON文件中加载的数据,本质上就是游戏数据。当用户输入一个方向时,我们会确保用户可以朝该方向移动,并将这个变化更新到currentRoom属性中。如果currentRoom属性为空,我们可以假设用户还没有进行任何移动,因此将第一个房间设为用户的起始房间。

此时,GameDataProcessor类的代码并不长,我将完整地展示在这里。

  
class_name GameDataProcessor

var InstructionSet = load("res://src/InstructionSet.gd")

var rooms
var currentRoom = null

func _init():
  rooms = loadJsonData("res://data/game1.json")

# 从JSON文件中加载游戏数据。
func loadJsonData(fileName):
  var file = FileAccess.open(fileName, FileAccess.READ)
  var json_string = file.get_as_text()
  file.close()

  var json = JSON.new()
  var error = json.parse(json_string)
  if error == OK:
    var data_received = json.data
    if typeof(data_received) == TYPE_DICTIONARY:
      return data_received
    else:
      assert(false, "JSON输出中出现意外数据")
  else:
    print("JSON解析错误: ", json.get_error_message(), " 在 ", json_string, " 的第 ", json.get_error_line(), " 行")
    assert(false, "JSON解析错误")

func process_action(action):
  # 如果当前房间为空,则从初始房间开始。
  if currentRoom == null:
    currentRoom = 'room1'
    return render_room(rooms[currentRoom])

  # 方向/操作是否有效?
  if rooms[currentRoom]['exits'].has(action) == false:
    return '我不明白你的意思!' + "\n"

  # 如果是一个方向,则将状态改变为新的房间。
  if rooms[currentRoom]['exits'][action].has('destination') == true:
    currentRoom = rooms[currentRoom]['exits'][action]['destination']

  # 返回新房间的文本描述
  return render_room(rooms[currentRoom])

# 渲染给定的房间,包括出口信息。
func render_room(room):
  var renderedRoom = ''
  renderedRoom += room['intro'] + "\n"

  renderedRoom += "\n可能的出口有:\n"

  for exit in room['exits']:
    renderedRoom += "- " + room['exits'][exit]['description'] + "\n"

  return renderedRoom
  

包含房间数据的JSON文件非常简单。它基本上由一个房间列表组成,每个房间包含一段介绍和一些出口信息,这些信息在我们向用户渲染房间信息时会用到。出口数据也用于检查用户是否可以朝某个特定方向移动。

以下是一个包含两个相互连接房间的JSON文件示例。

  
{
  "room1": {
    "intro": "你在一个房间里,房间里有一个燃烧着熊熊烈火的壁炉和一张古老的橡木桌。除此之外,房间里空无一物。",
    "exits": {
      "north": {
        "description": "向北有另一个房间。",
        "destination": "room2"
      }
    }
  },
  "room2": {
    "intro": "你在一条走廊上,东边有一扇门。",
    "exits": {
      "south": {
        "description": "向南,回到你来的地方。",
        "destination": "room1"
      }
    }
  }
}
  

以JSON作为游戏数据意味着我们实际上创建了一个可重新编程的游戏引擎。我们可以轻松地用其他JSON文件替换当前的文件,从而获得一个全新的文本冒险游戏,而无需更改一行代码。

现在我们已经有了让用户在游戏中移动的机制,接下来需要将这些功能整合起来。这将在我们之前创建的LineEdit类中完成,需要对该类进行修改,以便我们能够解析游戏数据并输出结果。

_ready()函数(在类被创建时调用)用于实例化处理游戏数据所需的对象。由于我们确定玩家还没有进行任何移动,所以调用GameDataProcessor对象的process_action()函数并传入一个空字符串,这会使GameDataProcessor对象设置其currentRoom属性并渲染第一个房间的输出。基本上,这就启动了游戏。

当用户在LineEdit节点中输入命令时,会触发_on_text_submitted()函数,该函数使用TextParser对象(同样在_ready()函数中创建)处理输入,并将指令发送到GameDataProcessor对象的process_action()函数。然后,该指令会被追加到GameText节点的文本中,我们在_ready()函数中找到了这个节点,并将其存储为该类的一个属性。

  
extends LineEdit

const TextParser = preload("res://src/TextParser.gd")
const GameDataProcessor = preload("res://src/GameDataProcessor.gd")

var gameText: RichTextLabel
var text_parser = null
var game_data_processor = null

# 节点首次进入场景树时调用。
func _ready():
  gameText = get_parent().get_parent().get_node("GameText")
  text_parser = TextParser.new()
  game_data_processor = GameDataProcessor.new()
  gameText.append_text(game_data_processor.process_action('') + "\n")
  self.grab_focus()

func _on_text_submitted(new_text):
  if (new_text.is_empty()):
    return

  # 清空文本区域的文本。
  self.set_text('')

  # 解析文本
  var instruction = text_parser.parse(new_text)

  # 发送到游戏数据处理器
  var output_text = ''
  output_text += " > " + new_text + "\n\n"
  output_text += game_data_processor.process_action(instruction)
  output_text += "\n"

  # 将输出传递到游戏文本区域
  gameText.append_text(output_text)
  

至此,从技术上来说,我们可以运行游戏,并在JSON文件中存储的各个房间之间进行导航了。

让我们更进一步,添加一些额外的命令。

四、添加物品和动作

现在我们已经有了在一个小迷宫中移动的动作,接下来可以向InstructionSet类中添加额外的动作,以扩展用户的操作方式。虽然在迷宫中移动很不错,但通过添加用户可以拾取的物品,我们可以让游戏变得更加有趣。

我们将“get”(拾取)、“open”(打开)和“close”(关闭)这几个动作添加到指令集中。在Drupal开发里,也经常需要类似扩展功能的操作,就如同给游戏添加新动作来丰富体验,Drupal模块开发也是这样不断为网站增添新特性。

  
class_name InstructionSet

...

const GET = "get"

const OPEN = "open"

const CLOSE = "close"
  

然后需要修改TextParser类,使其能够理解传递给它的指令。为了让玩家能够与对象进行交互,我们在TextParser类中添加了一个object属性。接着,我们使用一个正则表达式对象从文本数据中提取对象,并将其添加到object属性中。使用带有命名参数的正则表达式意味着我们可以通过向正则表达式对象请求正确匹配的字符串来提取对象。

  
class_name TextParser

var InstructionSet = load("res://src/InstructionSet.gd")

var object = null

# 将给定的输入字符串解析为指令。
func parse(text):
  match text:

...

  if text.begins_with('get '):
    var regex = RegEx.new()
    regex.compile("get\\s(?<object>.*(\\s.*)?)")
    var results = regex.search(text)
    object = results.get_string('object')
    return InstructionSet.GET

  if text.begins_with('open '):
    var regex = RegEx.new()
    regex.compile("open\\s(?<object>.*(\\s.*)?)")
    var results = regex.search(text)
    object = results.get_string('object')
    return InstructionSet.OPEN

  if text.begins_with('close '):
    var regex = RegEx.new()
    regex.compile("close\\s(?<object>.*(\\s.*)?)")
    var results = regex.search(text)
    object = results.get_string('object')
    return InstructionSet.CLOSE

  return InstructionSet.NOT_FOUND

func get_object():
  return object
  

这一更改意味着,当输入像“get key”(拾取钥匙)这样的指令时,会返回“get”指令,并且“key”会被设置到object属性中。

为了利用这一点,我们需要修改LineEdit类中的process_action()调用,以传递指令和在上一个指令中找到的对象。

  
  # 发送到游戏数据处理器
  var output_text = ''
  output_text += " > " + new_text + "\n\n"
  output_text += game_data_processor.process_action(instruction, text_parser.get_object())
  output_text += "\n"
  

然后需要修改process_action()函数,使其能够理解新的指令。

以下是在函数中添加“get”和“open”指令的代码。我们使用JSON游戏数据中的一些额外属性来检测对象是否可用以及门是否被锁上。

  
class_name GameDataProcessor

...

var inventory = {}

...

func process_action(action, object = null):

...

  if action == InstructionSet.GET and object != null:
    for item in rooms[currentRoom]['items']:
      if rooms[currentRoom]['items'][item]['name'] == object:
        inventory[item] = rooms[currentRoom]['items'][item]
        return '你拾取了 ' + object;
    return '这里没有 ' + object + "\n"

  if action == InstructionSet.OPEN and object != null:
    var direction = object.get_slice(' ', 0)
    var exit = object.get_slice(' ', 1)
    for item in rooms[currentRoom]['exits']:
      if item == direction:
        for inventoryItem in inventory:
          if rooms[currentRoom]['exits'][item]['key'] == inventoryItem:
            rooms[currentRoom]['exits'][item]['locked'] = false
            return '你打开了 ' + direction + ' 方向的门'
      else:
        return '你想打开哪个方向的门?'
    return '你没有这扇门的钥匙'
...

  # 返回新房间的文本描述
  return render_room(rooms[currentRoom])
  

我们添加了一个新的inventory属性,用于保存用户的物品清单。

注意,这里我们不再返回渲染后的房间信息,而是返回用户操作的结果。此外,我们还做了一些不同的处理。由于门的信息存储在从JSON数据加载的rooms数组中,这意味着我们可以根据需要操作这些数据。因此,当用户解锁一扇门时,我们将该房间中门的锁定状态设置为false,这实际上就打开了门。

现在我们只需要向JSON数组中添加额外的数据,这样用户就可以玩这个游戏了。

  
{
  "room1": {
    "intro": "你在一个房间里,房间里有一个燃烧着熊熊烈火的壁炉和一张古老的橡木桌。除此之外,房间里空无一物。",
    "items": {
      "key123": {
        "description": "你看到橡木桌上有一把钥匙。",
        "type": "key",
        "opens": "north",
        "name": "key"
      }
    },
    "exits": {
      "north": {
        "description": "向北有另一个房间。",
        "destination": "room2",
        "locked": true,
        "key": "key123"
      }
    }
  },
  "room2": {
    "intro": "你在一条走廊上,东边有一扇门。",
    "exits": {
      "south": {
        "description": "向南,回到你来的地方。",
        "destination": "room1"
      }
    }
  }
}
  

现在用户可以拾取一些物品并使用它们来打开门了。每个物品的描述是可配置的,这样描述就不一定总是“key”(钥匙),如果我们愿意,这也允许我们在未来扩展游戏,添加其他类型的谜题。

五、额外命令

有了这个基本结构,我们现在可以添加一些额外的命令来帮助玩家在游戏中更好地进行游戏。像“look”(查看)和“help”(帮助)这样的操作可以通过打印出一些文本帮助玩家更好地完成任务。我们还可以添加“reset”(重置)功能来重置游戏,以及“quit”(退出)功能来关闭游戏。

同样,要添加一个指令,我们只需要将指令代码添加到InstructionSet类中。在Drupal11的升级中,也会有类似不断添加新指令和功能的操作,以满足更多的需求。

  
class_name InstructionSet

... 

const LOOK = "look"

const HELP = "help"

const RESET = "reset"

const QUIT = "quit"
  

然后让TextParser能够从用户输入的信息中提取指令。对于“help”指令,我们允许有一点变化,也接受用户输入“help me”。

  
class_name TextParser

var InstructionSet = load("res://src/InstructionSet.gd")

# 将给定的输入字符串解析为指令。
func parse(text):
  match text:
...
    'look':
      return InstructionSet.LOOK
    'help':
      return InstructionSet.HELP
    'help me':
      return InstructionSet.HELP

    'reset':
      return InstructionSet.RESET
    'quit':
      return InstructionSet.QUIT
    'exit':
      return InstructionSet.QUIT

  return InstructionSet.NOT_FOUND

func get_object():
  return object
  

完成这些后,我们只需要修改process_action()函数,使TextParser对象返回的指令能够实际执行相应的操作。

“look”命令非常简单,我们只需要重新渲染当前房间即可。

  
  # 响应“look”命令。
  if action == InstructionSet.LOOK:
    return render_room(rooms[currentRoom])
  

“help”命令将向用户打印出可用的命令。

  
  # 响应“help”命令。
  if action == InstructionSet.HELP:
    var helpText = ''
    helpText += '指令说明:' + "\n"
    helpText += '- 使用“look”查看你所在的房间。' + "\n"
    helpText += '- 使用“north”、“south”、“east”、“west”向相应方向移动。' + "\n"
    helpText += '- 使用“open”或“close”与门进行交互。' + "\n"
    helpText += '- 使用“get <object>”拾取物品。' + "\n"
    helpText += '- 使用“reset”重置游戏,或使用“exit”退出游戏。' + "\n"
    return helpText
  

“reset”命令将当前房间设置为null,重置inventory属性,然后递归调用process_action()函数。这会重置用户的游戏进度,因为currentRoom属性记录了他们当前所在的房间。此外,我们还需要重置房间数据以恢复任何已锁定门的状态,这通过重新从JSON文件中加载数据来实现。

  
  # 响应“reset”命令。
  if action == InstructionSet.RESET:
    currentRoom = null
    inventory = {}
    rooms = loadJsonData("res://data/game1.json")
    return process_action(null)
  

最后,“quit”命令只是调用游戏引擎的quit()函数,从而关闭游戏。

  
  # 响应“quit”命令。
  if action == InstructionSet.QUIT:
    Engine.get_main_loop().quit()
    return '再见...'
  

基本上就是这样。完成这些最后的修改后,我们现在有了一个功能完整的游戏,用户可以根据需要退出游戏。

六、成品游戏

以下是游戏运行时的一些截图,是在Godot的调试播放器中运行的。

游戏的初始状态非常简单,它会向玩家显示房间里有一把钥匙,玩家可以拾取。

过了一会儿,玩家解锁了门,并在各个房间中继续探索。

为了保持简单,游戏只包含3个房间和一个单钥匙开门的谜题。一旦用户进入了砖石房间,游戏中就没有太多其他可做的事情了,只能原路返回。

七、总结

编写这个项目很有趣。GDScript语言使用起来非常直观,尽管我确实需要查阅一些关于类、字符串匹配和数组操作的知识,但文档非常出色。在Drupal开发里,选择合适的语言和工具也至关重要,而且丰富的文档能帮助开发者更好地完成项目。

游戏本身虽然能正常运行,但相当简单,且有一定的局限性。我只创建了3个房间和一个单钥匙锁谜题,但通过向JSON文件中添加更多内容,有潜力创建出有一定情节的游戏。由于这是一个用于教育目的的游戏,我决定尽可能简化操作数量和JSON文件。

一个很大的疏忽是游戏没有结束状态。一旦你到达第三个房间,就没有太多其他事情可做了。我添加了返回上一个房间的功能,但我想,你完全可以创建一个带有胜利(或游戏结束)信息的房间,只需要不设置任何出口即可。

如果你想查看所有代码(甚至运行游戏),我公司已将其上传到了GitHub仓库。如果你发现任何需要修正的问题,请随时打开issue。

我这里创建游戏的方式并不是创建文本冒险游戏的唯一方式。在查找关于文本冒险游戏的信息时,我遇到了“状态模型”的概念。在这种模型中,你创建的世界的当前状态会存储在内存中(或者至少一些状态标志会存储在内存中),并且随着你的移动和与世界的交互,状态会发生变化。虽然我已经通过当前房间和库存属性开始朝着这个方向发展,但更好的做法是使用一个状态对象,我可以根据用户的操作更新它。这样做还可以让我们将数据序列化到文件中,从而创建一个存档系统。

游戏本身还集成了一个名为GUT的框架,即Godot单元测试框架。我添加这个框架是为了测试游戏的文本解析部分,但GUT引擎可以做的远远不止这些。要运行单元测试,你只需要安装GUT插件,然后从Godot界面中运行它们即可。我很高兴能找到这个插件,因为它让我能够在不运行游戏和不用长时间在LineEdit节点中输入各种内容的情况下,对游戏引擎进行全面测试。就如同Drupal升级时,也需要全面的测试来保证升级的稳定和兼容性。

我确实考虑过将游戏发布到itch.io上让大家试试,但把这样一个简单的游戏发布到那个网站上似乎有点多余。你可以轻松地在Godot中运行这个项目,所以不妨下载Godot试试看。