我的自习室—让生活从这里沉淀 - 2025年1月
这里您可以静下心来学习，有自己的爱好，我喜欢编程，例如Godot游戏开发，HTML、CSS和Javascript前台开发，c#，sqlserver后台数据开发，还喜欢记录网海发现的各类好软件。
让我们通过制作 RPG 来学习 Godot 4 — 第 16 部分：等级和 XP
现在我们有了基本的敌人和玩家，我们需要给自己一个杀死敌人的动机。我们可以通过升级系统来实现这一点，该系统在获得一定数量的 XP 后提高玩家的等级。如果玩家升级，他们会得到奖励，包括拾取物品和属性（健康、耐力）补充。我们还会在升级时增加他们的最大健康和最大耐力值。您将在本部分中学习到的内容：· 如何使用 Popup 节点。· 如何暂停场景树。· 如何允许/禁止输入处理。· 如何改变节点的处理模式。· 如何（可选）更改鼠标光标的图像和可见性。升级概述升级弹出窗口准备就绪后，打开你的游戏项目，在你的播放器脚本中通过你的信号定义三个新信号，它们将更新我们的 xp、xp 要求和级别值。### Player.gd # Custom signals signal health_updated signal stamina_updated signal ammo_pickups_updated signal health_pickups_updated signal stamina_pickups_updated signal xp_updated signal level_updated signal xp_requirements_updated接下来，我们需要创建这些信号在发出时将更新的变量。您可以将玩家的初始经验值、等级和所需经验值更改为您想要的任何值。### Player.gd # XP and levelling var xp = 0 var level = 1 var xp_requirements = 100如果您还记得我们在前面的部分如何更新健康和耐力 GUI 元素，您就会知道我们必须在玩家场景中的 XP 和 Level 元素中创建函数，然后将它们连接到玩家场景中的信号。在您的 Player Scene 中，在您的 UI CanvasLayer 下方，将新脚本附加到您的 XP 和 Level ColorRect。确保将其保存在您的 GUI 文件夹下方。我们的 XP ColorRect 也应该有两个值，一个用于我们的 XP，一个用于我们的 XP 要求，因此请继续复制您的 Value 节点。其变换值如下图所示。我们想要从这些 ColorRects（而不是 Label）更新 Value 子节点，因此让我们继续为每个新脚本创建一个函数来更新我们的 XP 和 Level 值。### XPAmount.gd extends ColorRect # Node refs @onready var value = $Value @onready var value2 = $Value2 #return xp func update_xp_ui(xp): #return something like 0 value.text = str(xp) #return xp_requirements func update_xp_requirements_ui(xp_requirements): #return something like / 100### LevelAmount.gd extends ColorRect # Node refs @onready var value = $Value # Return level func update_level_ui(level): #return something like 0 value.text = str(level)现在我们只需将这些 UI 元素函数连接到我们的 Player 脚本中每个新创建的信号。### Player.gd extends CharacterBody2D # Node references @onready var animation_sprite = $AnimatedSprite2D @onready var health_bar = $UI/HealthBar @onready var stamina_bar = $UI/StaminaBar @onready var ammo_amount = $UI/AmmoAmount @onready var stamina_amount = $UI/StaminaAmount @onready var health_amount = $UI/HealthAmount @onready var xp_amount = $UI/XP @onready var level_amount = $UI/Level @onready var animation_player = $AnimationPlayer func _ready(): # Connect the signals to the UI components' functions health_updated.connect(health_bar.update_health_ui) stamina_updated.connect(stamina_bar.update_stamina_ui) ammo_pickups_updated.connect(ammo_amount.update_ammo_pickup_ui) health_pickups_updated.connect(health_amount.update_health_pickup_ui) stamina_pickups_updated.connect(stamina_amount.update_stamina_pickup_ui) xp_updated.connect(xp_amount.update_xp_ui) xp_requirements_updated.connect(xp_amount.update_xp_requirements_ui) level_updated.connect(level_amount.update_level_ui)我们希望在杀死敌人后更新我们的 xp 数量，因此要做到这一点，我们需要在我们的 Player 脚本中创建一个新函数，它将更新我们的 xp 值并发出xp_updated信号。### Player.gd # older code # ----------------- Level & XP ------------------------------ #updates player xp func update_xp(value): xp += value #emit signals xp_requirements_updated.emit(xp_requirements) xp_updated.emit(xp) level_updated.emit(level)我们可以在任意需要更新 xp 的地方调用此函数，比如在我们的add_pickups()函数中，或者在敌人的damage()函数中判断其死亡。### Player.gd # older code # ---------------------- Consumables ------------------------------------------ # Add the pickup to our GUI-based inventory func add_pickup(item): if item == Global.Pickups.AMMO: ammo_pickup = ammo_pickup + 3 # + 3 bullets ammo_pickups_updated.emit(ammo_pickup) print("ammo val:" + str(ammo_pickup)) if item == Global.Pickups.HEALTH: health_pickup = health_pickup + 1 # + 1 health drink health_pickups_updated.emit(health_pickup) print("health val:" + str(health_pickup)) if item == Global.Pickups.STAMINA: stamina_pickup = stamina_pickup + 1 # + 1 stamina drink stamina_pickups_updated.emit(stamina_pickup) print("stamina val:" + str(stamina_pickup)) update_xp(5)### Enemy.gd # older code #will damage the enemy when they get hit func hit(damage): health -= damage if health > 0: #damage animation_player.play("damage") else: #death #stop movement timer_node.stop() direction = Vector2.ZERO #stop health regeneration set_process(false) #trigger animation finished signal is_attacking = true #Finally, we play the death animation and emit the signal for the spawner. animation_sprite.play("death") #add xp values player.update_xp(70) death.emit() #drop loot randomly at a 90% chance if rng.randf() < 0.9: var pickup = Global.pickups_scene.instantiate() pickup.item = rng.randi() % 3 #we have three pickups in our enum get_tree().root.get_node("Main/PickupSpawner/SpawnedPickups").call_deferred("add_child", pickup) pickup.position = position在本部分中，我们将更多地构建此功能，因为我们仍然需要更新xp_requirements并运行检查以查看我们的玩家是否已获得足够的 xp 来升级。如果他们获得了足够的 xp，我们需要将当前 xp 数量重置回零，并增加玩家的级别和所需的 xp 值。然后我们需要发出信号来通知我们的游戏这些值的变化。### Player.gd # older code # ----------------- Level & XP ------------------------------ #updates player xp func update_xp(value): xp += value #check if player leveled up after reaching xp requirements if xp >= xp_requirements: #reset xp to 0 xp = 0 #increase the level and xp requirements level += 1 xp_requirements *= 2 #emit signals xp_requirements_updated.emit(xp_requirements) xp_updated.emit(xp) level_updated.emit(level)如果您现在运行您的场景并杀死一些敌人（确保您将他们的伤害值改为零，以便他们无法在这次测试中杀死您），您将看到您的 xp 和等级值更新。如果我们的玩家升级了，我们希望屏幕显示玩家升级的通知，以及他们升级后获得的奖励摘要。我们将通过向玩家的 UI 节点添加 CanvasLayer 节点来实现这一点。在您的玩家场景中，向您的 UI 层添加一个新的 CanvasLayer 节点。将其重命名为 LevelUpPopup。在此 CanvasLayer 节点中，添加两个 ColorRects 和一个Button节点。第一个 ColorRect 将包含我们的升级标签，第二个 ColorRect 将包含我们的奖励摘要。该按钮将允许玩家确认通知并继续游戏。您可以按如下方式重命名它们：将消息节点的颜色更改为 #d6c376，并将其大小更改为 (x：142，y：142)；位置更改为 (x：4，y：4)；anchor_preset 更改为 (中心)。将 Rewards 节点拖到 Message 节点中，使其成为该节点的子节点。将其颜色更改为 #365655，并将其大小更改为 (x: 100, y: 75)；位置更改为 (x: 20, y: 30)；anchor_preset 更改为 (center)。另外，将 Confirm 节点拖到 Message 节点中，使其成为该节点的子节点。将其颜色更改为 #365655，并将其大小更改为 (x: 100, y: 75)；位置更改为 (x: 20, y: 30)；anchor_preset 更改为 (center)。将其字体更改为“Schrodinger”，并将其文本更改为“CONTINUE”。现在，在 Message 节点的顶部，让我们添加一个新的 Label 节点来显示我们的欢迎文本“Level Up!”。将其字体更改为“Arcade Classic”，字体大小更改为 15。然后更改其 anchor_preset (center-top)、水平对齐 (center)、垂直对齐 (center) 和位置 (y: 5)。在您的奖励节点中，添加六个新的标签节点。将其重命名如下：更改其属性如下：全部：Text = “1”Font = “Schrödinger”Font-size = 10Anchor Preset = center-topHorizontal Alignment = centerVertical Alignment = center等级提升：Position= y: 0健康增加：Position= y: 10体力增加：Position= y: 20健康获得：Position= y: 30耐力提升获得：Position= y:40拾取弹药获得：Position= y: 50创建 Popup 后，我们现在可以继续隐藏 Popup。您可以在 Inspector 面板的 Canvas Item > Visibility 下执行此操作，或者只需单击节点旁边的眼睛图标即可将其隐藏。我们需要返回Player 场景中的update_xp()函数来更新检查玩家是否升级的条件。如果他们正在升级，我们需要暂停游戏，显示包含所有奖励值的弹出窗口，并且仅在玩家单击确认按钮时隐藏弹出窗口。在这个函数中，我们必须提高玩家的最大生命值和耐力，并为他们提供一些弹药以及健康和耐力饮料。完成此操作后，我们需要在 UI 元素中反映这些变化。如果我们想暂停游戏，只需使用SceneTree.paused方法。如果游戏暂停，则不会接受玩家或我们的任何输入，因为一切都暂停了。除非我们更改节点的处理模式。Godot 中的每个节点都有一个“处理模式”，用于定义其处理时间。可以在检查器中节点的Node属性下找到并更改它。以下是每个模式告诉节点要做的事情：继承：节点将根据父节点的状态工作或被处理。如果父节点的处理模式是可暂停的，则该节点也将是可暂停的，等等。可暂停：仅当游戏未暂停时，节点才会工作或被处理。暂停时：仅当游戏暂停时，节点才会工作或被处理。始终：无论游戏是否暂停，节点都会工作或被处理。已禁用：该节点将不起作用，也不会被处理。我们需要 LevelUpPopup 节点仅在游戏暂停时工作。这样我们就可以单击“确认”按钮来取消游戏暂停，从而允许其他节点继续处理，因为它们只有在游戏处于未暂停状态时才工作或处理输入。让我们将 LevelUpPopup 的“处理模式”更改为“ WhenPaused”。您可以在“节点”>“处理”>“模式”下找到此选项。因为我们更改了 LevelUpPopup 节点的处理模式，所以它的所有子节点也将继承该处理模式，因此当游戏暂停时，它们都将正常工作。在代码中暂停游戏之前，我们还需要首先允许通过set_process_input方法处理输入。此方法启用或禁用输入处理。然后我们将增加健康、耐力、经验值、等级和拾取值，并将这些更改反映在我们的 UI 上！让我们在代码中进行这些更改。### Player.gd extends CharacterBody2D # Node references @onready var animation_sprite = $AnimatedSprite2D @onready var health_bar = $UI/HealthBar @onready var stamina_bar = $UI/StaminaBar @onready var ammo_amount = $UI/AmmoAmount @onready var stamina_amount = $UI/StaminaAmount @onready var health_amount = $UI/HealthAmount @onready var xp_amount = $UI/XP @onready var level_amount = $UI/Level @onready var animation_player = $AnimationPlayer @onready var level_popup = $UI/LevelPopup # ----------------- Level & XP ------------------------------ #updates player xp func update_xp(value): xp += value #check if player leveled up after reaching xp requirements if xp >= xp_requirements: #allows input set_process_input(true) #pause the game get_tree().paused = true #make popup visible level_popup.visible = true #reset xp to 0 xp = 0 #increase the level and xp requirements level += 1 xp_requirements *= 2 #update their max health and stamina max_health += 10 max_stamina += 10 #give the player some ammo and pickups ammo_pickup += 10 health_pickup += 5 stamina_pickup += 3 #update signals for Label values health_updated.emit(health, max_health) stamina_updated.emit(stamina, max_stamina) ammo_pickups_updated.emit(ammo_pickup) health_pickups_updated.emit(health_pickup) stamina_pickups_updated.emit(stamina_pickup) xp_updated.emit(xp) level_updated.emit(level) #reflect changes in Label $UI/LevelPopup/Message/Rewards/LevelGained.text = "LVL: " + str(level) $UI/LevelPopup/Message/Rewards/HealthIncreaseGained.text = "+ MAX HP: " + str(max_health) $UI/LevelPopup/Message/Rewards/StaminaIncreaseGained.text = "+ MAX SP: " + str(max_stamina) $UI/LevelPopup/Message/Rewards/HealthPickupsGained.text = "+ HEALTH: 5" $UI/LevelPopup/Message/Rewards/StaminaPickupsGained.text = "+ STAMINA: 3" $UI/LevelPopup/Message/Rewards/AmmoPickupsGained.text = "+ AMMO: 10" #emit signals xp_requirements_updated.emit(xp_requirements) xp_updated.emit(xp) level_updated.emit(level)最后，我们需要让确认按钮能够关闭弹出窗口并取消游戏暂停。我们可以将其pressed()信号连接到 Player 脚本来实现这一点。在这个新创建的_on_confirm_pressed()：函数中，我们将简单地再次隐藏弹出窗口并取消游戏暂停。### Player.gd # older code # close popup func _on_confirm_pressed(): level_popup.visible = false get_tree().paused = false现在，如果我们运行场景并射击足够多的敌人，我们将看到弹出窗口显示我们的奖励值，如果我们单击确认按钮，弹出窗口将关闭，然后我们可以用新的值继续游戏！显示和隐藏光标我不喜欢光标总是显示的方式。无论游戏是否暂停，我们的光标总是停留在屏幕上。由于这不是点击游戏，因此当游戏未暂停时，光标没有理由显示，因为我们会花时间四处奔跑和射击敌人。因此，我们的光标应该只在我们处于菜单屏幕（例如暂停或主菜单）时显示，甚至在我们的对话屏幕中显示 - 即当游戏暂停并且我们需要光标进行输入时。幸运的是，这是一个快速解决方案。我们可以通过 Input 单例MouseMode方法更改鼠标光标的可见性。在我们的 Player 脚本中，我们将在游戏暂停时显示光标，如果没有暂停，我们将隐藏光标。### Player.gd # ----------------- Level & XP ------------------------------ #updates player xp func update_xp(value): xp += value #check if player leveled up after reaching xp requirements if xp >= xp_requirements: #allows input set_process_input(true) Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) #pause the game #emit signals xp_requirements_updated.emit(xp_requirements) xp_updated.emit(xp) level_updated.emit(level) # close popup func _on_confirm_pressed(): level_popup.visible = false get_tree().paused = false Input.set_mouse_mode(Input.MOUSE_MODE_HIDDEN)我们还需要在加载时隐藏光标。### Player.gd func _ready(): # Connect the signals to the UI components' functions health_updated.connect(health_bar.update_health_ui) stamina_updated.connect(stamina_bar.update_stamina_ui) ammo_pickups_updated.connect(ammo_amount.update_ammo_pickup_ui) health_pickups_updated.connect(health_amount.update_health_pickup_ui) stamina_pickups_updated.connect(stamina_amount.update_stamina_pickup_ui) xp_updated.connect(xp_amount.update_xp_ui) xp_requirements_updated.connect(xp_amount.update_xp_requirements_ui) level_updated.connect(level_amount.update_level_ui) # Reset color animation_sprite.modulate = Color(1,1,1,1) Input.set_mouse_mode(Input.MOUSE_MODE_HIDDEN)我们还可以更改鼠标光标的图像。如果您进入“项目设置”>“显示”>“鼠标光标”，则可以更改鼠标光标的图像。您可以在此处找到免费的鼠标光标包。我使用了VOiD1 Gaming 的免费基本光标包。现在，如果您运行游戏，当暂停状态改变时，光标应该被隐藏/显示。加载显示经验值如果我们更改变量的值并运行游戏，您可能会注意到 Level、XP 和 Pickup 值没有更新。我们需要通过进入 UI 脚本并在加载时从 Player 脚本调用我们的值来解决这个问题。HealthAmount.gd扩展了ColorRect### HealthAmount.gd extends ColorRect # Node ref @onready var value = $Value @onready var player = $"../.." # Show correct value on load func _ready(): value.text = str(player.health_pickup) # Update ui func update_health_pickup_ui(health_pickup): value.text = str(health_pickup)### StaminaAmount.gd extends ColorRect # Node ref @onready var value = $Value @onready var player = $"../.." # Show correct value on load func _ready(): value.text = str(player.stamina_pickup) # Update ui func update_stamina_pickup_ui(stamina_pickup): value.text = str(stamina_pickup)### LevelAmount.gd extends ColorRect # Node refs @onready var value = $Value @onready var player = $"../.." # On load func _ready(): value.text = str(player.level) # Return level func update_level_ui(level): #return something like 0 value.text = str(level) ### XPAmount.gd extends ColorRect # Node refs @onready var value = $Value @onready var value2 = $Value2 @onready var player = $"../.." # On load func _ready(): value.text = str(player.xp) value2.text = "/" + str(player.xp_requirements) #return xp func update_xp_ui(xp): #return something like 0 value.text = str(xp) #return xp_requirements func update_xp_requirements_ui(xp_requirements): #return something like / 100 value2.text = "/" + str(xp_requirements)### AmmoAmount.gd extends ColorRect # Node ref @onready var value = $Value @onready var player = $"../.." # Show correct value on load func _ready(): value.text = str(player.ammo_pickup) # Update ui func update_ammo_pickup_ui(ammo_pickup): value.text = str(ammo_pickup)现在，如果您运行场景，您的值应该正确显示，这在我们稍后加载游戏时很有用！恭喜，您的玩家现在可以因杀死坏人而获得奖励。我们还没有 100% 完成，因为在下一部分中，我们将添加一个基本的 NPC 和任务，完成此任务后，玩家还会获得 XP 奖励。记得保存您的项目，下一部分见。
让我们通过制作 RPG 来学习 Godot 4 — 第 15 部分：玩家死亡和游戏结束
现在我们的玩家可以被敌人伤害，我们可以继续实现该功能，以便他们可以死亡，如果他们已经死亡，游戏就结束了，并显示游戏结束屏幕。稍后，我们将在此游戏结束系统的基础上进行构建，以便我们重定向到主菜单屏幕。您将在本部分中学习到的内容：· 使用 AnimationPlayer 节点进一步练习。· 进一步练习使用 CanvasLayer 节点。在 Player 场景中，向 UI 节点添加新的 Label 和 ColorRect。将 ColorRect 重命名为“GameOver”。它应该如下所示：将 GameOver 节点的大小更改为游戏窗口的大小 (x: 320, y: 180)。此外，将其 Color 属性更改为 #d6c376。选择你的 Label 节点，将其文本更改为“GAME OVER!!!”。确保它是大写的，并将锚点预设更改为居中。让我们为这个标签分配一个新字体。在“控制”>“主题覆盖”>“字体”下，快速加载新字体并选择字体为“Schrödinger.tff”。将字体大小设置为 20。现在通过单击旁边的眼睛图标来隐藏 GameOver 节点。在窗口底部的动画面板中，单击动画标签创建一个新动画。将此动画命名为“game_over”。对于此动画，我们希望当玩家死亡时，游戏结束屏幕会慢慢显示出来。因此，我们将更改 ColorRect 节点 (GameOver) 的调制属性。添加新的 Property Track 并将其连接到 GameOver 节点。选择调制值属性并添加两个键 - 一个在关键帧 0，另一个在关键帧 1。选择第一个关键帧 (0)，然后将 Value 属性中的 Alpha 值更改为 0。这是因为我们希望窗口从不可见变为可见。您可以播放动画来查看我们想要的效果。现在，在我们的死亡条件之下的玩家伤害代码中，让我们播放这个动画。### Player.gd # older code # ------- Damage & Death ------------ #does damage to our player func hit(damage): health -= damage health_updated.emit(health, max_health) if health > 0: #damage animation_player.play("damage") health_updated.emit(health, max_health) else: #death set_process(false) animation_player.play("game_over")如果您运行场景，并且玩家被击中次数足够多，以至于其生命值低于 0，则应该显示游戏结束屏幕。稍后我们将使此屏幕在返回主屏幕时可暂停。现在我们的玩家可以“死亡”，游戏会通知我们游戏结束了。我们将在接下来的几部分中继续改进这个系统，但在下一部分中，我们将添加等级和 XP 功能，这将在完成任务或杀死敌人后增加我们的 XP 和等级。记得保存你的项目，下一部分见。
让我们通过制作 RPG 来学习 Godot 4 — 第 14 部分：敌人射击和造成伤害
如果玩家是游戏中唯一能够造成伤害的实体，那就不公平了。这会让他们成为毫无威胁的强大恶霸。这就是为什么在这一部分我们要赋予敌人反击的能力，他们将能够对我们的玩家造成真正的伤害！这个过程与我们赋予玩家射击和造成伤害的能力时所做的类似。这次只是反过来。这部分可能需要一段时间，所以请放松，让我们的敌人值得成为我们的敌人！您将在本部分中学习到的内容：· 如何使用 AnimationPlayer 节点。· 如何使用 RayCast2D 节点。· 如何使用调制值。· 如何复制/粘贴节点以及复制对象。敌人射击之前，我们在敌人脚本中添加了一些尚未使用的子弹和攻击变量。我们无法控制敌人，因此我们需要某种方式来确定他们是否面对我们。我们可以使用RayCast2D节点，它将创建一条射线或线，该射线或线将击中敌人周围有碰撞的节点。然后，将使用此射线投射来查看它们是否击中了玩家的碰撞（称为“玩家”）。如果是，我们将触发敌人向我们射击，因为这意味着他们面对着我们。这将产生一颗子弹，如果击中我们的玩家，将对我们造成伤害。让我们将此节点添加到我们的敌人场景树中。您将看到一条射线或箭头现在从您的敌人那里射出。您可以在 Inspector 面板中更改此射线的长度。我现在将我的设置为 50。我们想将这条射线移向敌人所面对的方向。由于我们在_physics_process()函数中执行了所有移动代码，因此我们可以在那里执行此操作。我们将射线投射方向转向敌人，乘以他们的射线投射箭头长度的值（对我来说是 50）。这是他们能够撞击其他碰撞的程度。### Enemy.gd extends CharacterBody2D # Node refs @onready var player = get_tree().root.get_node("Main/Player") @onready var animation_sprite = $AnimatedSprite2D @onready var animation_player = $AnimationPlayer @onready var timer_node = $Timer @onready var ray_cast = $RayCast2D # older code # ------------------------- Movement & Direction --------------------- # Apply movement to the enemy func _physics_process(delta): var movement = speed * direction * delta var collision = move_and_collide(movement) #if the enemy collides with other objects, turn them around and re-randomize the timer countdown if collision != null and collision.get_collider().name != "Player": #direction rotation direction = direction.rotated(rng.randf_range(PI/4, PI/2)) #timer countdown random range timer = rng.randf_range(2, 5) #if they collide with the player #trigger the timer's timeout() so that they can chase/move towards our player else: timer = 0 #plays animations only if the enemy is not attacking if !is_attacking: enemy_animations(direction) # Turn RayCast2D toward movement direction if direction != Vector2.ZERO: ray_cast.target_position = direction.normalized() * 50如果您在“调试”菜单中启用碰撞可见性，并运行游戏，您将看到敌人四处奔跑，射线投射会击中他们所面对方向的任何碰撞。让我们将我们的播放器组织到一个名为“播放器”的新组下。现在，我们可以更改process()函数，使其在与“player”组中的节点发生碰撞时生成子弹并播放敌人的射击动画。整个过程类似于ui_attack下的 Player 代码— 无需计算时间。### Enemy.gd # older code #------------------------------------ Damage & Health --------------------------- func _process(delta): #regenerates our enemy's health health = min(health + health_regen * delta, max_health) #get the collider of the raycast ray var target = ray_cast.get_collider() if target != null: #if we are colliding with the player and the player isn't dead if target.is_in_group("player"): #shooting anim is_attacking = true var animation = "attack_" + returned_direction(new_direction) animation_sprite.play(animation)产卵子弹我们还将在我们的func _on_animated_sprite _finished():函数中生成子弹，因为只有在播放完射击动画后，我们才希望将子弹添加到主场景中。在实例化场景之前，我们需要为敌人创建一个 Bullet 场景。这是因为我们现有的 Bullet 场景被告知要忽略与玩家的碰撞，因此复制我们现有的场景并替换代码以忽略敌人会更容易。继续复制 Bullet 场景和脚本，然后将它们重命名为 EnemyBullet.tscn 和 EnemyBullet.gd。将新复制的场景根重命名为 EnemyBullet，并将 EnemyBullet 脚本附加到该脚本。同时将 Timer 和 AnimationPlayer 的信号重新连接到 EnemyBullet 脚本，而不是 Bullet 脚本。在 EnemyBullet 脚本中，在on_body_entered () 函数中交换“Player”和“Enemy”字符串。### EnemyBullet.gd # older code # ---------------- Bullet ------------------------- # Position func _process(delta): position = position + speed * delta * direction # Collision func _on_body_entered(body): # Ignore collision with Enemy if body.is_in_group("enemy"): return # Ignore collision with Water if body.name == "Map": #water == Layer 0 if tilemap.get_layer_name(Global.WATER_LAYER): return # If the bullets hit player, damage them if body.is_in_group("player"): body.hit(damage) # Stop the movement and explode direction = Vector2.ZERO animated_sprite.play("impact")现在在您的全局脚本中，预加载 EnemyBullet 场景。### Global.gd extends Node # Scene resources @onready var pickups_scene = preload("res://Scenes/Pickup.tscn") @onready var enemy_scene = preload("res://Scenes/Enemy.tscn") @onready var bullet_scene = preload("res://Scenes/Bullet.tscn") @onready var enemy_bullet_scene = preload("res://Scenes/EnemyBullet.tscn")现在，我们可以回到敌人场景，并在func _on_animated_sprite_2d_animation_finished()函数中生成子弹。我们必须创建另一个 EnemyBullet 场景实例，在其中更新其伤害、方向和位置，作为敌人发射子弹时所面对的方向，以及敌人前方 8 个像素的位置 — 因为我们希望他们的射程比我们的玩家更远。### Enemy.gd # older code # Bullet & removal func _on_animated_sprite_2d_animation_finished(): if animation_sprite.animation == "death": get_tree().queue_delete(self) is_attacking = false # Instantiate Bullet if animation_sprite.animation.begins_with("attack_"): var bullet = Global.enemy_bullet_scene.instantiate() bullet.damage = bullet_damage bullet.direction = new_direction.normalized() # Place it 8 pixels away in front of the enemy to simulate it coming from the guns barrel bullet.position = player.position + new_direction.normalized() * 8 get_tree().root.get_node("Main").add_child(bullet)攻击玩家现在我们需要继续在 Player 脚本中添加一个 damage 函数，以便EnemyBullet 脚本中的body.hit(damage)代码可以工作。在此之前，我们还将把我们添加到 Enemy 的 damage 动画添加到 Player 中。如果您想练习使用 AnimationPlayer，您可以重新创建它，但我只是要从 Enemy 场景复制节点并将其粘贴到 Player 场景中。因为我们的玩家场景中已经有一个 AnimatedSprite2D 节点，所以它会自动将动画连接到我们的调制值。在我们的代码中，我们可以继续创建伤害函数。此函数类似于我们在敌人场景中创建的函数，在该场景中，我们被子弹击中后会受到伤害。红色的“伤害”指示动画也会根据伤害播放，并且我们的健康值会更新。我们不会在这一部分中添加死亡功能，因为我们想在下一部分中与游戏结束屏幕一起实现它。### Player.gd extends CharacterBody2D # Node references @onready var animation_sprite = $AnimatedSprite2D @onready var health_bar = $UI/HealthBar @onready var stamina_bar = $UI/StaminaBar @onready var ammo_amount = $UI/AmmoAmount @onready var stamina_amount = $UI/StaminaAmount @onready var health_amount = $UI/HealthAmount @onready var animation_player = $AnimationPlayer # older code # ------------------- Damage & Death ------------------------------ #does damage to our player func hit(damage): health -= damage health_updated.emit(health, max_health) if health > 0: #damage animation_player.play("damage") health_updated.emit(health) else: #death set_process(false) #todo: game overYour final code should look like this.现在如果你运行你的场景，你的敌人应该追逐你并向你射击，当子弹击中你的玩家节点时，它会降低玩家的健康值。我们还需要将动画播放器的 animation_finished() 信号连接到主脚本并在那里重置我们的值，这样即使动画在完成过程中卡住了，它也会重置调制。### Player.gd func _ready(): # Connect the signals to the UI components' functions health_updated.connect(health_bar.update_health_ui) stamina_updated.connect(stamina_bar.update_stamina_ui) ammo_pickups_updated.connect(ammo_amount.update_ammo_pickup_ui) health_pickups_updated.connect(health_amount.update_health_pickup_ui) stamina_pickups_updated.connect(stamina_amount.update_stamina_pickup_ui) # Reset color animation_sprite.modulate = Color(1,1,1,1) func _on_animation_player_animation_finished(anim_name): # Reset color animation_sprite.modulate = Color(1,1,1,1)恭喜，你现在有了一个可以射击玩家的敌人！接下来，我们将赋予玩家死亡的能力，并实现游戏结束系统。记得保存你的项目，下一部分见。本节代码下载。
让我们通过制作 RPG 来学习 Godot 4 — 第 13 部分：敌人死亡时掉落战利品
我们终于可以射击并杀死敌人了，但我们可以做得更好。我希望我们升级敌人的死亡功能，以便我们的敌人掉落我们可以捡起的战利品。这种战利品掉落的几率为 90%，并且战利品将从我们的 Pickups 枚举中随机抽取。这意味着我们的敌人可能会掉落弹药、健康饮料或耐力饮料。您将在本部分中学习到的内容：· 如何随机化百分比和枚举返回值。在 damage() 函数的死亡条件中，我们需要使用随机方法来随机化战利品变化百分比。如果值小于 0.9（90%），我们将随机化一个拾取物。这个拾取物也将根据我们拥有的枚举值数量（即 3）进行随机化。然后，这个拾取物将被添加到我们的场景中，并在我们被杀死的敌人的位置生成。### Enemy.gd extends Area2D # older code #will damage the enemy when they get hit func hit(damage): health -= damage if health > 0: #damage animation_player.play("damage") else: #death #stop movement timer_node.stop() direction = Vector2.ZERO #stop health regeneration set_process(false) #trigger animation finished signal is_attacking = true #Finally, we play the death animation and emit the signal for the spawner. animation_sprite.play("death") death.emit() #drop loot randomly at a 90% chance if rng.randf() < 0.9: var pickup = Global.pickups_scene.instantiate() pickup.item = rng.randi() % 3 #we have three pickups in our enum get_tree().root.get_node("Main/PickupSpawner/SpawnedPickups").call_deferred("add_child", pickup) pickup.position = position什么是 call_deffered？call_deferred是一种允许您将另一个方法的调用推迟到当前帧或周期结束时的方法。这在您想要更改场景树或其他对象但又不希望这些更改在当前函数或信号期间立即生效的情况下特别有用。如果你现在运行场景并杀死敌人，他们掉落战利品的几率应该为 90%。你可以根据需要将此值降低到 50% 或 60% — 根据你对敌人死亡时掉落战利品的概率的偏好进行设置。现在我们的敌人会掉落战利品，我们不必不断寻找拾取物。在下一部分中，我们将为我们的敌人添加功能，使其能够向我们的玩家射击并伤害我们。记得保存您的项目，下一部分见。本节代码下载。
让我们通过制作 RPG 来学习 Godot 4 — 第 12 部分：玩家射击和造成伤害
如果不能射击敌人，敌人还有什么用呢？在这一部分中，我们将创建子弹，让玩家可以朝他们面对的方向射击。然后，我们将添加一个动画，显示如果我们的子弹击中敌人，敌人会受到伤害。如果子弹击中敌人的程度足以使其生命值耗尽，他们也会死亡。稍后，我们将提高玩家的 XP，并在敌人死亡时为其添加战利品。但现在，让我们系好安全带，因为这部分会很长！您将在本部分中学习到的内容：· 如何使用 AnimationPlayer 节点。· 如何使用 RayCast2D 节点。· 如何使用调制值。· 如何使用 Time 类。生成的子弹让我们首先创建玩家射击或攻击时产生的子弹。此场景将类似于我们的 Pickups 场景的结构。创建一个以 Area2D 节点为根的新场景。将此节点重命名为 Bullet 并将其保存在您的 Scenes 文件夹下。由于没有形状，因此会出现警告消息。我们可以通过向其添加 CollisionShape2D 节点并以 RectangleShape2D 作为形状来修复此问题。我们还需要查看我们的节点/子弹。我们的子弹将有一个撞击动画，因此我们需要添加一个 AnimatedSprite2D 节点。在 Inspector 面板中添加新的 SpriteFrames 资源，然后在下面的 SpriteFrames 窗格中添加一个名为“impact”的新动画。我们将用于子弹的 spritesheet 可以在 Assets > FX > Death Explosion.png 下找到。水平方向我们算 8 帧，垂直方向我们算 1 帧，因此让我们相应地更改值以裁剪动画帧。此外，只为动画选择前三帧。将 FPS 值更改为 10，并将其循环值关闭。我们还需要将一个启用了自动播放的 Timer 节点添加到场景中。当子弹在未击中或撞击任何东西后需要“自毁”时，此计时器将发出timeout()信号。最后，向场景添加脚本并将其保存在脚本文件夹下。好的，现在我们可以开始讨论我们的 Bullet 场景需要做什么。我们首先需要定义一些变量来设置子弹的速度、方向和伤害。我们还需要再次引用我们的 Tilemap 节点，以便我们可以忽略与某些层的碰撞，例如我们的水，它添加了碰撞，但它不应该阻止子弹。### Bullet.gd extends Area2D # Bullet variables @onready var tilemap = get_tree().root.get_node("Main/Map") var speed = 80 var direction : Vector2 var damage然后我们需要计算子弹射出后的位置。如果玩家移动，则应为每一帧重新计算此位置。因此，我们可以在内置的process()函数中计算它，以便它在每一帧移动期间更新其方向。我们可以通过将当前位置乘以方向乘以当前帧捕获的速度（作为增量）来计算此位置。### Bullet.gd extends Area2D # Bullet variables @onready var tilemap = get_tree().root.get_node("Main/Map") var speed = 80 var direction : Vector2 var damage # ---------------- Bullet ------------------------- # Position func _process(delta): position = position + speed * delta * direction当我们进行拾取时，我们将 Area2D 的 body_entered() 信号连接到我们的场景，以将拾取物添加到我们玩家的库存中。我们想对我们的 Bullet 场景做同样的事情，但这次我们将检查进入的身体是否是我们的敌人，以便我们可以伤害他们。我们还想检查进入 Bullets 碰撞的身体是否是来自 TileMap 的 Player 或“水”层，因为我们想忽略这些碰撞。将 body_entered() 信号连接到 Bullet 脚本。您将看到它在脚本末尾创建了一个新的“func _on_body_entered(body):”函数。在这个新函数中，让我们实现上面设定的目标。#Collision detection for the bullet func _on_body_entered(body): # Ignore collision with Player if body.name == "Player": return # Ignore collision with Water if body.name == "Map": #water == Layer 0 if tilemap.get_layer_name(0): return # If the bullets hit an enemy, damage them if body.name.find("Enemy") >= 0: #todo: add damage/hit function to enemy scene pass我们还不能伤害敌人，因为他们没有设置任何生命值变量或函数。我们稍后会这样做，但现在，让我们停止子弹的移动，并将其动画更改为“撞击”，如果它没有击中任何东西。我们这样做是因为我们不想让一颗随机的子弹在我们的场景中漂浮。### Bullet.gd extends Area2D # Bullet variables @onready var tilemap = get_tree().root.get_node("Main/Map") var speed = 80 var direction : Vector2 var damage # ---------------- Bullet ------------------------- # Position func _process(delta): position = position + speed * delta * direction # Collision func _on_body_entered(body): # Ignore collision with Player if body.name == "Player": return # Ignore collision with Water if body.name == "Map": #water == Layer 0 if tilemap.get_layer_name(Global.WATER_LAYER): return # If the bullets hit an enemy, damage them if body.name.find("Enemy") >= 0: #todo: add damage/hit function to enemy scene pass我们还不能伤害敌人，因为他们没有设置任何生命值变量或函数。我们稍后会这样做，但现在，让我们停止子弹的移动，并将其动画更改为“撞击”，如果它没有击中任何东西。我们这样做是因为我们不想让一颗随机的子弹在我们的场景中漂浮。### Bullet.gd extends Area2D # Node refs @onready var tilemap = get_tree().root.get_node("Main/Map") @onready var animated_sprite = $AnimatedSprite2D # older code # ---------------- Bullet ------------------------- # Position func _process(delta): position = position + speed * delta * direction # Collision func _on_body_entered(body): # Ignore collision with Player if body.name == "Player": return # Ignore collision with Water if body.name == "Map": #water == Layer 0 if tilemap.get_layer_name(Global.WATER_LAYER): return # If the bullets hit an enemy, damage them #todo: add damage/hit function to enemy scene # Stop the movement and explode direction = Vector2.ZERO animated_sprite.play("impact")我们还需要删除场景中的子弹，因为它停止移动并停止播放“撞击”动画。我们之前在 Pickups 场景中做过这个，所以让我们继续将 AnimationSprite2D animation_finished信号连接到 Bullet 脚本。### Bullet.gd # older code # ---------------- Bullet ------------------------- # older code # Remove func _on_animated_sprite_2d_animation_finished(): if animated_sprite.animation == "impact": get_tree().queue_delete(self)### Bullet.gd # older code # ---------------- Bullet ------------------------- # older code # Remove func _on_animated_sprite_2d_animation_finished(): if animated_sprite.animation == "impact": get_tree().queue_delete(self)最后，在 Bullet 场景中，将 Timer 节点的timeout()信号连接到 Bullet 脚本。我们希望此函数在两秒钟内未击中任何东西后也播放“撞击”动画，因为播放此动画将触发animation_finished信号，从而将子弹从场景中删除。### Bullet.gd # older code # ---------------- Bullet ------------------------- # older code # Self-destruct func _on_timer_timeout(): animated_sprite.play("impact")不要忘记将 Timer 节点的等待时间更改为 2，以便它在 2 秒后播放动画，而不是 1 秒。射手现在，我们已经完成了 Bullet 场景，因为我们已经设置了子弹运动和动画，并且能够从场景中移除子弹。现在我们需要返回 Player 场景，以便我们可以通过ui_attack输入发射子弹。让我们在 Global 脚本中预加载 Bullet 脚本。### Global.gd extends Node # Scene resources @onready var pickups_scene = preload("res://Scenes/Pickup.tscn") @onready var enemy_scene = preload("res://Scenes/Enemy.tscn") @onready var bullet_scene = preload("res://Scenes/Bullet.tscn")在您的播放器脚本中，让我们定义一些变量来设置子弹伤害、重新加载时间和子弹发射时间。### Player.gd # older code # Bullet & attack variables var bullet_damage = 30 var bullet_reload_time = 1000 var bullet_fired_time = 0.5现在，我们可以将ui_attack输入更改为生成子弹、计算重新装填时间并移除弹药。生成这些子弹时，我们需要考虑子弹发射的时间与重新装填时间。我们希望玩家休息 1000 毫秒后才能发射下一轮。要在 Godot 中获取时间，我们可以使用Time 对象。Time 单例允许在各种格式之间转换时间，还可以从系统获取时间信息。我们将使用方法.get_ticks_msec()进行精确的时间计算。首先，我们将检查当前时间是否大于或等于bullet_fired_time并且我们是否有弹药可以射击（请确保首先为玩家分配一些弹药）。如果更大，则意味着我们可以发射一发子弹。然后我们将is_attacking布尔值返回为 true 并播放射击动画。我们将通过将 reload_time 添加到当前时间中来更新 bullet_fired_time。这意味着我们的玩家在发射下一发子弹之前将有 1000 毫秒的暂停时间。最后，我们将更新弹药拾取量。### Player.gd # older code func _input(event): #input event for our attacking, i.e. our shooting if event.is_action_pressed("ui_attack"): #checks the current time as the amount of time passed in milliseconds since the engine started var now = Time.get_ticks_msec() #check if player can shoot if the reload time has passed and we have ammo if now >= bullet_fired_time and ammo_pickup > 0: #shooting anim is_attacking = true var animation = "attack_" + returned_direction(new_direction) animation_sprite.play(animation) #bullet fired time to current time bullet_fired_time = now + bullet_reload_time #reduce and signal ammo change ammo_pickup = ammo_pickup - 1 ammo_pickups_updated.emit(ammo_pickup) # older code我们将在_on_animated_sprite_2d_animation_finished函数中生成子弹，因为只有在播放完射击动画后，我们才希望将子弹添加到主场景中。我们必须创建子弹场景的另一个实例，在其中我们将更新其伤害、方向和位置，作为玩家发射子弹时所面对的方向，以及玩家前方 4-5 像素的位置（你不希望子弹来自玩家内部，而是来自枪的“枪管”）。## ### Player.gd # older code # Reset Animation states func _on_animated_sprite_2d_animation_finished(): is_attacking = false # Instantiate Bullet if animation_sprite.animation.begins_with("attack_"): var bullet = Global.bullet_scene.instantiate() bullet.damage = bullet_damage bullet.direction = new_direction.normalized() # Place it 4-5 pixels away in front of the player to simulate it coming from the guns barrel bullet.position = position + new_direction.normalized() * 4 get_tree().root.get_node("Main").add_child(bullet)现在，如果您运行场景，按下 CTRL 发射子弹（ui_attack 输入操作）后应该会产生一颗子弹。对于子弹来说，它有点大，所以让我们改变它的大小！在 Bullet 场景中，选择 AnimatedSprite2D 节点，将其 Transform 属性下的比例值从 1 更改为 0.4。另外，将子弹特效的第四帧添加到“冲击”动画中。抱歉现在添加它给您带来不便，我只是觉得它在最后一刻看起来更好！现在，如果你运行你的场景，你的子弹应该小得多。它还应该在 2 秒后或撞击另一个碰撞体时自毁。现在我们需要添加敌人受到子弹撞击伤害的功能。敌人的攻击回到我们的 Bullet 脚本，我添加了一个#todo，因为我们仍然没有在敌人场景中设置伤害函数或健康变量。我们现在就去做。在 Enemy 脚本中，让我们设置其健康变量。就像玩家一样，我们需要变量来存储其健康、最大健康、健康再生，以及死亡时发出的信号。### Enemy.gd extends CharacterBody2D # Node refs @onready var player = get_tree().root.get_node("Main/Player") @onready var animation_sprite = $AnimatedSprite2D # Enemy stats @export var speed = 50 var direction : Vector2 # current direction var new_direction = Vector2(0,1) # next direction var animation var is_attacking = false var health = 100 var max_health = 100 var health_regen = 1 # Direction timer var rng = RandomNumberGenerator.new() var timer = 0 # Custom signals signal death我们将在其_process(delta)函数中计算其健康再生，因为我们想在每一帧都计算它。当我们计算其 updated_health 值时，我们已经在 Player 脚本中计算了这一点。### Enemy.gd extends CharacterBody2D # older code #-------------- Damage & Health ------------------ func _process(delta): #regenerates our enemy's health health = min(health + health_regen * delta, max_health)让我们继续创建伤害函数。这将是我们在 Player 和 Bullet 场景中调用的函数，用于在子弹/攻击撞击时对敌人造成伤害。在此之前，我们还需要为敌人提供一些攻击变量。我们可以从 Player 脚本中复制攻击变量。### Enemy.gd extends CharacterBody2D # older code # Bullet & attack variables var bullet_damage = 30 var bullet_reload_time = 1000 var bullet_fired_time = 0.5伤害函数会根据玩家或攻击者传入的子弹伤害来减少敌人的生命值。如果他们的生命值大于零，敌人就会受到伤害。如果小于或等于零，敌人就会死亡。### Enemy.gd # older code #------------------------------------ Damage & Health --------------------------- func _process(delta): #regenerates our enemy's health health = min(health + health_regen * delta, max_health) #will damage the enemy when they get hit func hit(damage): health -= damage if health > 0: #damage pass else: #death pass我们将慢慢完成伤害函数，因此让我们首先添加一个动画来表明我们的敌人已被击中。为此，我们可以通过AnimationPlayer节点更改敌人的调制值。调制值指的是节点的颜色，因此简单来说，我们将使用 AnimationPlayer 将敌人的颜色短暂地变成红色，以便我们可以看到它们受到了伤害。每当您需要为非精灵项目（例如标签或颜色）制作动画时，您都将使用此 AnimationPlayer 节点。让我们将此节点添加到我们的敌人场景中。16.jpg何时使用 AnimatedSprite2D 节点与 AnimationPlayer 节点？使用 AnimatedSprite2D 节点实现简单的逐帧 2D 精灵动画。使用 AnimationPlayer 实现涉及多个属性、节点或音频和函数调用等附加功能的复杂动画。我们将动画添加到编辑器底部动画面板中的 AnimationPlayer 节点。要添加动画，请点击“动画”标签并输入“新建”。我们将这个新动画命名为“损坏”。您的新动画将分配长度“1”，您可以看到值从 0 到 1。让我们将此长度更改为 0.2，因为我们希望此损坏指示器非常短。您可以按住 CTRL 并使用鼠标滚轮缩放来放大/缩小您的轨迹。图 14：动画面板概览我们希望此动画短暂地改变 AnimatedSprite2D 节点的 modulate（颜色）值，这是我们可以在节点的 Inspector 面板中更改的属性。因此，我们需要添加一个新的 Property Track。单击“+ Add Track”并选择 Property Track。将此轨迹连接到您的 AnimatedSprite2D 节点，因为这是您想要设置动画的节点。接下来，我们需要选择要更改的节点的属性。我们需要调节值，因此请从列表中选择它。现在我们有了想要更改的属性，我们需要在轨道上插入关键帧。这些将是动画关键帧，定义动画的起点和/或终点。让我们插入两个关键帧：一个在我们的 0 值（起点）上，一个在我们的 0.2 值（终点）上。要插入关键帧，只需右键单击轨道并选择“插入关键帧”。如果您单击立方体或键，则会打开“检查器”面板，您将看到您可以在“值”下方的该帧中更改节点的调制值。我们希望调制值从关键帧 0 处的红色变为关键帧 1 处的白色。为了使调制值变为红色，我们只需将其RGB值更改为 (255, 0, 0)。我们还想改变动画的轨迹速率。如果你现在运行动画，调制颜色会逐渐从红色变为白色。相反，我们希望它在达到 0.2 关键帧值时立即从红色变为白色。为此，我们可以改变它的轨迹速率，它位于弯曲的白线下方的轨迹旁边。Godot 有三种跟踪速率选项：连续：在每一帧上更新属性。离散：仅更新关键帧上的属性。触发器：仅更新关键帧或触发器上的属性。我们需要将跟踪速率从连续改为离散。它应该看起来像这样：现在，如果你运行此动画，它会暂时将敌人精灵的颜色更改为红色，然后再恢复为其默认颜色。我们可以回到我们的伤害功能，如果敌人受到伤害就播放此动画。### Enemy.gd extends CharacterBody2D # Node refs @onready var player = get_tree().root.get_node("Main/Player") @onready var animation_sprite = $AnimatedSprite2D @onready var animation_player = $AnimationPlayer # older code #------------------------------------ Damage & Health --------------------------- func _process(delta): #regenerates our enemy's health health = min(health + health_regen * delta, max_health) #will damage the enemy when they get hit func hit(damage): health -= damage if health > 0: #damage animation_player.play("damage") else: #death pass现在，为了伤害敌人，我们必须将敌人添加到一个组中。这将允许我们在 Bullet 场景中使用 Area2D 节点，仅当它们属于我们的“敌人”组时才伤害我们的身体。单击敌人场景中的根节点，然后在 Groups 属性下将敌人分配给“敌人”组。什么是群组？组是组织和管理场景树中节点的一种方式。它们提供了一种便捷的方式，可以将操作或逻辑应用于具有共同特征或用途的一组节点。让我们回到 Bullet 脚本中的 #todo，并将其替换为我们刚刚创建的损坏函数。### Bullets.gd extends Area2D # older code # ---------------- Bullet ------------------------- # Position func _process(delta): position = position + speed * delta * direction # Collision func _on_body_entered(body): # Ignore collision with Player if body.name == "Player": return # Ignore collision with Water if body.name == "Map": #water == Layer 0 if tilemap.get_layer_name(Global.WATER_LAYER): return # If the bullets hit an enemy, damage them if body.is_in_group("enemy"): body.hit(damage) # Stop the movement and explode direction = Vector2.ZERO animated_sprite.play("impact")敌人死亡最后，对于本节，我们需要添加敌人死亡的功能。我们已经为此创建了信号，现在我们只需要更新 Enemy 脚本中的现有代码以播放死亡动画并从场景树中移除敌人，以及更新 Enemy Spawner 代码以连接到此信号并更新敌人数量。让我们从死亡条件下的 Enemy 脚本开始。当我们的敌人死亡时，我们要做的第一件事就是停止处理其移动和方向的计时器。我们还需要停止 process ()函数重新生成敌人的健康值，因此我们将set_process值设置为 false。### Enemy.gd # older code #will damage the enemy when they get hit func hit(damage): health -= damage if health > 0: #damage animation_player.play("damage") else: #death #stop movement timer_node.stop() direction = Vector2.ZERO #stop health regeneration set_process(false) #trigger animation finished signal is_attacking = true #Finally, we play the death animation and emit the signal for the spawner. animation_sprite.play("death") death.emit()我们还需要将 is_attacking 变量设置为 true，以便触发动画完成信号，这样我们就可以在死亡动画播放后从场景中移除节点。我们只是希望敌人停止，播放其死亡动画，然后发出信号表示它已死亡，以便可以生成新的敌人。### Enemy.gd #------------------------------------ Damage & Health --------------------------- # remove func _on_animated_sprite_2d_animation_finished(): if animation_sprite.animation == "death": get_tree().queue_delete(self) is_attacking = false现在我们需要转到我们的 EnemySpawner 脚本来创建一个函数，该函数在我们的敌人脚本发出死亡信号后从我们的敌人数量中删除一个点。###EnemySpawner.gd # ----------------- Spawning -------------------------- # older code # Remove enemy func _on_enemy_death(): enemy_count = enemy_count - 1我们希望这个函数与我们的生成函数中的信号相连接，这样我们的生成器就知道一个敌人已被移除，并且应该生成一个新的敌人。###EnemySpawner.gd # ----------------- Spawning -------------------- func spawn_enemy(): var attempts = 0 var max_attempts = 100 # Maximum number of attempts to find a valid spawn location var spawned = false while not spawned and attempts < max_attempts: # Randomly select a position on the map var random_position = Vector2( rng.randi() % tilemap.get_used_rect().size.x, rng.randi() % tilemap.get_used_rect().size.y ) # Check if the position is a valid spawn location if is_valid_spawn_location(Global.GRASS_LAYER, random_position) || is_valid_spawn_location(Global.SAND_LAYER, random_position): var enemy = Global.enemy_scene.instantiate() enemy.death.connect(_on_enemy_death) # add this enemy.position = tilemap.map_to_local(random_position) + Vector2(16, 16) / 2 spawned_enemies.add_child(enemy) spawned = true else: attempts += 1 if attempts == max_attempts: print("Warning: Could not find a valid spawn location after", max_attempts, "attempts.")最后，回到我们的 Enemy 脚本，我们需要在伤害动画播放后以及敌人生成时重置敌人的调制值。这将防止我们的敌人在受到伤害后生成或保持红色。将 AnimationPlayer 节点的animation_finished信号连接到 Enemy 脚本。### Enemy.gd # older code func _ready(): rng.randomize() # Reset color animation_sprite.modulate = Color(1,1,1,1) # Reset color func _on_animation_player_animation_finished(anim_name): animation_sprite.modulate = Color(1,1,1,1)因此，我们的玩家现在可以发射子弹，如果子弹击中敌人，就会对其造成伤害。击中三次后，敌人就会被杀死，并被从场景中移除！这就是这一部分的全部内容。这项工作相当繁重，如果你能坚持到这一步，恭喜你坚持不懈！接下来，我们将在敌人死亡时产生战利品，然后我们将添加他们攻击我们的玩家角色并对我们造成伤害的功能！记得保存你的游戏项目，下一部分见。本节代码下载。
让我们通过制作 RPG 来学习 Godot 4 — 第 11 部分：生成敌人 AI
想象一下，手动将 50 多个敌人添加到我们的场景中需要做多少工作。这不仅看起来很混乱，而且还意味着我们的场景中只有 50 个敌人，如果我们杀死他们所有人，他们就会消失，直到我们重新加载游戏。为了解决这个小难题，我们可以创建一个敌人生成器，它将在整个地图的随机位置以恒定值生成我们的敌人，这意味着我们的敌人永远不会多于或少于我们定义的敌人。每次我们杀死一个敌人并将其移除时，生成器都会生成一个新的敌人，依此类推。我们还将使我们的生成器足够灵活，以便我们可以在 Inspector 面板中更改生成区域和最大敌人数。这意味着我们可以在主场景中实例化多个生成器，以便在不同区域生成 x 个敌人。这听起来有趣吗？好吧，你还在等什么？让我们生成一些敌人吧！您将在本部分中学习到的内容：· 如何使用计时器节点。· 如何创建场景参考。· 如何引用其他场景中的节点。· 如何引用 TileMap 属性。· 如何使用矩形属性。首先，让我们从主场景中删除敌人场景的所有实例。我们不会再像这样实例化我们的敌人，因为生成器会处理好这件事。让我们创建一个以Node2D为根的新场景。这将允许我们将敌人节点绘制到场景中。将此 Node2D 重命名为“EnemySpawner”并将其保存在您的 Scenes 文件夹下。我们需要向此场景添加一个 Timer 节点，以便我们每秒可以生成一个敌人 — 除非我们已经达到最大敌人数量。启用 AutoStart，因为我们希望此计时器在游戏开始时立即启动，以便我们开始生成敌人。我们还需要返回到敌人场景并禁用那里的计时器，因为我们不希望敌人在完成生成之前开始移动。稍后我们将在代码中启动此计时器。将脚本附加到您的根节点并将其保存在您的脚本文件夹下。最后，让我们添加一个新的 Node2D 节点，我们将在下面组织生成的敌人。在此脚本中，我们需要定义一些变量。这些变量将：· 随机化敌人的生成位置。· 设置并导出敌人最大值。· 设置并导出敌人当前的生成数量。· 设置对我们地图的 tilemap 属性的引用，以便我们的敌人不会出现在某些图层上（例如，我们的水和树叶）。###EnemySpawner.gd extends Node2D # Node refs @onready var spawned_enemies = $SpawnedEnemies @onready var tilemap = get_tree().root.get_node("Main/Map") # Enemy stats @export var max_enemies = 20 var enemy_count = 0 var rng = RandomNumberGenerator.new()在我们的全局脚本中，我们还将加载对敌人场景的引用。### Global.gd extends Node # Scene resources @onready var pickups_scene = preload("res://Scenes/Pickup.tscn") @onready var enemy_scene = preload("res://Scenes/Enemy.tscn")为了生成敌人，我们需要创建一个函数，该函数将在游戏开始时生成现有敌人，​​并在计时器超时时生成其余敌人。我们可以将函数称为spawn_enemy()。在此函数中，我们将创建对敌人场景的引用实例。当我们创建场景实例时，它会将其保存为资源，而无需每次调用​​时都从磁盘加载它。这节省了空间和时间。###EnemySpawner.gd # older code # ----------------------- Spawning ------------------ func spawn_enemy(): var enemy = Global.enemy_scene.instantiate()现在我们有了敌人场景的实例，我们可以通过add_child()方法将敌人添加为节点层次结构的子节点。简而言之，我们将敌人从敌人场景添加到生成器的场景树中。###EnemySpawner.gd # older code # --------------------------------- Spawning ------------------------------------ func spawn_enemy(): var enemy = Global.enemy_scene.instantiate() spawned_enemies.add_child(enemy)如果你从逻辑上思考，你就会知道你不能随便在任意位置生成敌人。如果我们的游戏在建筑物下生成敌人怎么办？或者在水中生成敌人怎么办？我们将无法接近敌人，因此为了防止发生此问题，我们必须创建一个函数来定义敌人的有效生成位置。我们之前在 Pickups 生成器中做过这个。现在，根据你添加到 Tilemap 的图层，下一部分可能与你这边的有所不同。在我们添加地图的部分，我们创建了以下图层：正如您所知，我们为每个图层分配了一个 ID。我们从 0 开始计数，这意味着water == 0, grass == 1, sand == 2，foliage ==3。我们只希望敌人出现在草（1）或沙子（2）图层上。我们不希望其他图层（0、3）出现的地方出现它们。由于我们将在 Pickup 生成功能和 Enemy 生成中引用这些层，因此让我们在 Global 脚本中重新定义 Main 脚本中的层常量。然后，我们还需要在 Main 脚本中正确引用这些常量。### Global.gd extends Node # Scene resources @onready var pickups_scene = preload("res://Scenes/Pickup.tscn") @onready var enemy_scene = preload("res://Scenes/Enemy.tscn") # Pickups enum Pickups { AMMO, STAMINA, HEALTH } # TileMap layers const WATER_LAYER = 0 const GRASS_LAYER = 1 const SAND_LAYER = 2 const FOLIAGE_LAYER = 3 const EXTERIOR_1_LAYER = 4 const EXTERIOR_2_LAYER = 5### Main.gd # older code # --------------------- Pickup spawning ------------------ # Valid pickup spawn location func is_valid_spawn_location(layer, position): var cell_coords = Vector2(position.x, position.y) # Check if there's a tile on the water, foliage, or exterior layers if map.get_cell_source_id(Global.WATER_LAYER, cell_coords) != -1 || map.get_cell_source_id(Global.FOLIAGE_LAYER, cell_coords) != -1 || map.get_cell_source_id(Global.EXTERIOR_1_LAYER, cell_coords) != -1 || map.get_cell_source_id(Global.EXTERIOR_2_LAYER, cell_coords) != -1: return false # Check if there's a tile on the grass or sand layers if map.get_cell_source_id(Global.GRASS_LAYER, cell_coords) != -1 || map.get_cell_source_id(Global.SAND_LAYER, cell_coords) != -1: return true return false现在在我们的 EnemySpawner 脚本中，让我们从主脚本中复制并粘贴整个is_valid_spawn_location()函数。用 tilemap 替换变量map。###EnemySpawner.gd # --------------------------------- Spawning ------------------------------------ # older code # Valid spawn location func is_valid_spawn_location(layer, position): var cell_coords = Vector2(position.x, position.y) # Check if there's a tile on the water, foliage, or exterior layers if tilemap.get_cell_source_id(Global.WATER_LAYER, cell_coords) != -1 || tilemap.get_cell_source_id(Global.FOLIAGE_LAYER, cell_coords) != -1 || tilemap.get_cell_source_id(Global.EXTERIOR_1_LAYER, cell_coords) != -1 || tilemap.get_cell_source_id(Global.EXTERIOR_2_LAYER, cell_coords) != -1: return false # Check if there's a tile on the grass or sand layers if tilemap.get_cell_source_id(Global.GRASS_LAYER, cell_coords) != -1 || tilemap.get_cell_source_id(Global.SAND_LAYER, cell_coords) != -1: return true return false现在，在我们的 spawn_enemy 函数中，我们需要随机选择地图上的一个位置。然后，我们需要使用 is_valid_spawn_location 函数检查该位置是否是有效的生成位置。如果有效，我们将在该位置生成敌人。如果无效，我们将尝试另一个随机位置。这再次与我们生成拾取物时所做的类似。###EnemySpawner.gd # --------------------------------- Spawning ------------------------------------ func spawn_enemy(): var attempts = 0 var max_attempts = 100 # Maximum number of attempts to find a valid location var spawned = false while not spawned and attempts < max_attempts: # Randomly select a position on the map var random_position = Vector2( rng.randi() % tilemap.get_used_rect().size.x, rng.randi() % tilemap.get_used_rect().size.y ) # Check if the position is a valid spawn location if is_valid_spawn_location(Global.GRASS_LAYER, random_position) || is_valid_spawn_location(Global.SAND_LAYER, random_position): # Spawn enemy var enemy = Global.enemy_scene.instantiate() enemy.position = tilemap.map_to_local(random_position) + Vector2(16, 16) / 2 spawned_enemies.add_child(enemy) spawned = true else: attempts += 1 if attempts == max_attempts: print("Warning: Could not find a valid spawn location after", max_attempts, "attempts.")接下来，我们需要生成敌人。我们可以在 Timer 节点的timeout()信号中执行此操作。将信号连接到场景根，您将看到func _on_timer_timeout():函数添加到脚本末尾。如果敌人数量不超过最大敌人数量，我们将调用spawn_enemies函数。这将每 1 秒生成一个敌人，直到生成允许的最大敌人数量。###EnemySpawner.gd # --------------------------------- Spawning ------------------------------------ # Spawn enemy func _on_timer_timeout(): if enemy_count < max_enemies: spawn_enemy() enemy_count = enemy_count + 1现在如果你在主场景中创建 EnemySpawner 场景的实例（确保主场景中没有敌人场景），然后运行游戏，你会注意到敌人出现了。如果您不想让敌人出现在场景中，只需在 Inspector 面板中将您的生成器值设置为 0。确保 TileMap 节点的变换属性设置为 (0,0) — 否则你的敌人将会以偏移量生成！拾取生成器趁此机会，让我们创建一个包含 PickupSpawner 的新场景。这将使我们的项目更具动态性和可重复使用性。重新创建您为 EnemySpawner 采取的步骤（创建新场景、添加节点、连接脚本、连接超时信号）。请不要向此场景添加计时器节点现在，在您的 PickupSpawner.gd 脚本中，将您在主脚本中添加的代码复制到您新创建的脚本中。### PickupSpawner.gd extends Node2D # Node refs @onready var map = get_tree().root.get_node("Main/Map") @onready var spawned_pickups = $SpawnedPickups var rng = RandomNumberGenerator.new() func _ready(): # Spawn between 5 and 10 pickups var spawn_pickup_amount = rng.randf_range(5, 10) spawn_pickups(spawn_pickup_amount) # ------------------------ Pickup spawning ------------ # Valid pickup spawn location func is_valid_spawn_location(layer, position): var cell_coords = Vector2(position.x, position.y) # Check if there's a tile on the water, foliage, or exterior layers if map.get_cell_source_id(Global.WATER_LAYER, cell_coords) != -1 || map.get_cell_source_id(Global.FOLIAGE_LAYER, cell_coords) != -1 || map.get_cell_source_id(Global.EXTERIOR_1_LAYER, cell_coords) != -1 || map.get_cell_source_id(Global.EXTERIOR_2_LAYER, cell_coords) != -1: return false # Check if there's a tile on the grass or sand layers if map.get_cell_source_id(Global.GRASS_LAYER, cell_coords) != -1 || map.get_cell_source_id(Global.SAND_LAYER, cell_coords) != -1: return true return false # Spawn pickup func spawn_pickups(amount): var spawned = 0 var attempts = 0 var max_attempts = 1000 # Arbitrary number, adjust as needed while spawned < amount and attempts < max_attempts: attempts += 1 var random_position = Vector2(randi() % map.get_used_rect().size.x, randi() % map.get_used_rect().size.y) var layer = randi() % 2 if is_valid_spawn_location(layer, random_position): var pickup_instance = Global.pickups_scene.instantiate() pickup_instance.item = Global.Pickups.values()[randi() % 3] pickup_instance.position = map.map_to_local(random_position) + Vector2(16, 16) / 2 spawned_pickups.add_child(pickup_instance) spawned += 1然后，在主场景中删除 SpawnedPickups 节点，并实例化 PickupSpawner。您的主脚本应如下所示。### Main.gd extends Node2D现在，当你运行场景时，你的拾取物和敌人应该会出现！如果你靠近敌人，敌人应该仍然会四处游荡并追逐玩家。接下来，我们将为玩家添加射击和伤害敌人的功能。记得保存你的游戏项目，下一部分见。本节代码下载。
让我们通过制作 RPG 来学习 Godot 4 — 第 10 部分：动画 AI 运动
如果敌人只是以静态图像的形式漂浮在我们的地图上，那它有什么用呢？这不行，所以让我们开始着手实现动画，让我们的敌人活过来。我还给你准备了一个惊喜：我们已经编写了大部分敌人的动画代码。嗯，不是真的，我的意思是它仍然在 Player 代码中，但这意味着我们可以继续复制和粘贴一些代码，这极大地加快了我们的开发时间。您将在本部分中学习到的内容：· 如何向不可控节点添加动画。· 进一步练习向量。在您的 Player 脚本中，我们想要将两个完整的函数复制到我们的 Enemy 脚本中。您应该复制的第一个函数是您的 func player_animations(direction: Vector2)函数，第二个函数是您的func returned_direction(direction: Vector2)函数。将Enemy 脚本中的player_animations()函数重命名为enemy_animations()。### Enemy.gd extends CharacterBody2D # Node refs @onready var player = get_tree().root.get_node("Main/Player") @onready var animation_sprite = $AnimatedSprite2D # Enemy stats @export var speed = 50 var direction : Vector2 # current direction var new_direction = Vector2(0,1) # next direction var animation # older code # Animation Direction func returned_direction(direction : Vector2): #it normalizes the direction vector to make sure it has length 1 (1, or -1 up, down, left, and right) var normalized_direction = direction.normalized() var default_return = "side" if normalized_direction.y > 0: return "down" elif normalized_direction.y < 0: return "up" elif normalized_direction.x > 0: #(right) $AnimatedSprite2D.flip_h = false return "side" elif normalized_direction.x < 0: #flip the animation for reusability (left) $AnimatedSprite2D.flip_h = true return "side" #default value is empty return default_return # Animations func enemy_animations(direction : Vector2): #Vector2.ZERO is the shorthand for writing Vector2(0, 0). if direction != Vector2.ZERO: #update our direction with the new_direction new_direction = direction #play walk animation, because we are moving animation = "walk_" + returned_direction(new_direction) animation_sprite.play(animation) else: #play idle animation, because we are still animation = "idle_" + returned_direction(new_direction) animation_sprite.play(animation)要为敌人的移动激活这些动画，我们必须首先检查是否有其他动画正在播放（例如攻击或死亡动画），如果没有，我们将在physics_process()函数中播放它们。我们再次为玩家角色做了同样的事情，所以这对您来说应该不会太难理解。从玩家代码中复制is_attacking变量。### Enemy.gd extends CharacterBody2D # Node refs @onready var player = get_tree().root.get_node("Main/Player") @onready var animation_sprite = $AnimatedSprite2D # Enemy stats @export var speed = 50 var direction : Vector2 # current direction var new_direction = Vector2(0,1) # next direction var animation var is_attacking = false然后在physics_process()函数中，如果敌人没有攻击，我们就调用enemy_animations()函数来播放敌人的动画。### Enemy.gd # older code # ------------------------- Movement & Direction --------------------- # Apply movement to the enemy func _physics_process(delta): var movement = speed * direction * delta var collision = move_and_collide(movement) #if the enemy collides with other objects, turn them around and re-randomize the timer countdown if collision != null and collision.get_collider().name != "Player": #direction rotation direction = direction.rotated(rng.randf_range(PI/4, PI/2)) #timer countdown random range timer = rng.randf_range(2, 5) #if they collide with the player #trigger the timer's timeout() so that they can chase/move towards our player else: timer = 0 #plays animations only if the enemy is not attacking if !is_attacking: enemy_animations(direction)如果你要从在这里，你可能会注意到敌人会试图攻击你的玩家——但问题是，即使他们背对着我们的玩家，他们也会试图攻击你的玩家。 如果你从这里运行你的敌人场景，你可能会注意到敌人会试图攻击你的玩家 - 但问题是，即使他们背对着我们的玩家，他们也会试图攻击你的玩家。在我们的timeout()函数中，我们为动画设置了 new_direction，但这并没有准确更新我们的 new_direction。为了解决这个问题，我们需要确保在攻击期间准确设置我们的 new_direction，并且它与敌人所面对的方向同步。为此，我们将创建一个新函数，将我们的 new_direction 与敌人的实际移动方向同步。然后，每当敌人移动或旋转时，我们都会调用此函数。这将确保我们的 new_direction 准确地表示敌人攻击时所面对的方向。让我们创建一个新函数来同步我们的 new_direction。您可以在 timeout() 函数上方执行此操作。### Enemy.gd #older code #syncs new_direction with the actual movement direction and is called whenever the enemy moves or rotates func sync_new_direction(): if direction != Vector2.ZERO: new_direction = direction.normalized()然后，我们将在func _on_timer_timeout():函数中调用此函数，这是敌人决定其行为（是否应该攻击、追逐或随机漫游）的地方。这确保每当敌人更新其行为时，它也会相应地更新其动画方向。### Enemy.gd # older code # -------------- Movement & Direction ----------- func _on_timer_timeout(): # Calculate the distance of the player's relative position to the enemy's position var player_distance = player.position - position #turn towards player so that it can attack if player_distance.length() <= 20: new_direction = player_distance.normalized() sync_new_direction() direction = Vector2.ZERO #chase/move towards player to attack them elif player_distance.length() <= 100 and timer == 0: direction = player_distance.normalized() sync_new_direction() #random roam radius elif timer == 0: #this will generate a random direction value var random_direction = rng.randf() #This direction is obtained by rotating Vector2.DOWN by a random angle between 0 and 2π radians (0 to 360°). if random_direction < 0.05: #enemy stops direction = Vector2.ZERO elif random_direction < 0.1: #enemy moves direction = Vector2.DOWN.rotated(rng.randf() * 2 * PI) sync_new_direction()请记住，敌人会朝玩家开始攻击时最后已知位置的方向发起攻击，因此，在攻击时，他们转向玩家的新位置时出现延迟是正常的。例如，如果他们开始攻击时你在他们左边，然后突然跑向右边，他们会先完成向左的攻击动画，然后再转向右边。如果你现在运行场景，你会看到敌人角色空转并按照其当前方向进行动画处理。您可能会注意到此处的另一个问题：我们的侧面动画从未播放。这是因为我们现有的returned_direction()函数首先检查 y 方向，然后检查 x 方向。这意味着如果有任何 y 方向的移动，它将始终优先考虑上下动画而不是侧面动画。为了解决这个问题，我们应该在 x 方向占主导地位时优先考虑它：### Enemy.gd # older code # ------------------------- Movement & Direction --------------------- # Animation Direction func returned_direction(direction : Vector2): var normalized_direction = direction.normalized() var default_return = "side" if abs(normalized_direction.x) > abs(normalized_direction.y): if normalized_direction.x > 0: #(right) $AnimatedSprite2D.flip_h = false return "side" else: #flip the animation for reusability (left) $AnimatedSprite2D.flip_h = true return "side" elif normalized_direction.y > 0: return "down" elif normalized_direction.y < 0: return "up" #default value is empty return default_return现在我们的敌人将会播放他们的侧面动画。现在就这些了，因为我们将在接下来的几部分中实现攻击动画。在第 11 部分中，我们将添加我们的敌人生成器场景，这样我们就不必在场景中手动实例化x个敌人。记得保存你的游戏项目，下一部分见。本节代码下载。
让我们通过制作 RPG 来学习 Godot 4 — 第 9 部分：敌人 AI 设置
如果没有某种威胁或敌人需要击败，游戏就不算完整。在我们的游戏中，我们将有一个仙人掌敌人，它会以恒定值在地图上生成，这意味着在游戏循环期间，地图上的敌人数量永远不会多于或少于 x。如果玩家触碰这个敌人，它会伤害我们的玩家，它也会向我们的玩家射击。让我们开始介绍我们的敌人 AI。您将在本部分中学习到的内容：· 如何向不可控节点添加运动。· 如何使用计时器节点。· 如何使用 RandomNumberGenerator 类。· 如何使节点随机移动，以及如何向其他节点移动。图 13：敌人概况敌人场景设置我们的敌人场景将具有与玩家场景相同的结构，以 CharacterBody2D 作为其根节点，后跟 AnimatedSprite2D 节点和 CollisionShape2D 节点。因此，我们可以继续复制我们的玩家场景并将其重命名为“敌人”或“仙人掌”。我只打算用仙人掌作为敌人，所以我将我的仙人掌称为通用术语“敌人”，但如果您要有多个不同的敌人，您可以为“仙人掌”、“匪徒”、“风滚草”等创建一个场景。将场景根重命名为您所需的任何名称（例如敌人），然后将玩家脚本从场景中分离出来。您还应该删除 Camera2D 节点，因为我们不会跟踪此节点，并且断开on_animation_finished()信号与 AnimatedSprite2D 节点的连接。您的最终场景应如下图所示。必须不断监视此敌人场景以更新其行为。例如，假设我们希望它漫游 1 分钟，1 分钟后，我们希望它停止 30 秒，然后重新定向并再次漫游。我们可以使用Timer节点，该节点将在倒计时指定间隔直至达到 0 后发出其内置的timeout() 信号。因此，每次计时器超时，敌人都应该重新定向并再次漫游。您的计时器有两个选项：一次性（如果为 true，计时器将在到达 0 时停止。如果为 false，它将重新启动）和自动启动（如果为 true，计时器将在进入场景树时自动启动）。我们希望此计时器在游戏开始时立即启动，因为我们想使用它在一定时间后更新敌人的动作。因此，您需要在 Timer 节点的 Inspector 面板中启用 Autostart 属性。稍后，当我们为敌人的漫游添加功能时，我们将回到此计时器节点。现在，让我们像为玩家设置动画一样设置敌人的动画。我们已经为敌人设置了所有动画名称，因为他们将能够完全按照玩家所做的那样做，我们只需切换动画帧即可。为此，您需要删除动画中现有的精灵帧。对所有动画执行此操作，但不要删除任何动画。让我们从 attack_down 动画开始。选择“从精灵表添加新精灵帧”选项，然后在 Assets > Mobs > Cactus 目录中，您将找到所有敌人的精灵表。对于我们的 attack_down 动画，我们将使用“Cactus Front Sheet.png”表来创建动画。我们水平计算 11 帧，垂直计算 4 帧，因此请相应地更改您的数字以正确裁剪帧。对于向下攻击，我们将使用第三行的帧。我挑战你现在使用下表作为指南，自行添加其余动画。调动敌人现在我们希望能够自主移动敌人。为此，我们需要创建一些变量来存储敌人的方向，以及经过一段随机时间后的新方向。我们不会将其设置为等待 1 分钟后再重定向的固定时间，而是将该值随机化。我们还希望我们的敌人在与物体碰撞后重定向。让我们将一个新脚本附加到敌人场景并将其保存在脚本文件夹下。我们的敌人的移动方式与玩家的移动方式类似，因此让我们从 Player.gd 脚本中添加一些熟悉的变量来捕捉其移动速度和新方向。我们还需要创建一个变量来存储其当前方向。### Enemy.gd extends CharacterBody2D # Enemy movement speed @export var speed = 50 #it’s the current movement direction of the cactus enemy. var direction : Vector2 #direction and animation to be updated throughout game state var new_direction = Vector2(0,1) #only move one spaces当计时器在随机倒计时结束后用完时，我们的方向就会改变。我们将使用RandomNumberGenerator类生成此随机倒计时值。顾名思义，这是一个用于生成伪随机数的类。new() 方法用于从类创建对象。### Enemy.gd # older code # RandomNumberGenerator to generate timer countdown value var rng = RandomNumberGenerator.new() #timer reference to redirect the enemy if collision events occur & timer countdown reaches 0 var timer = 0如果敌人在一定半径内发现我们的玩家，我们还需要将敌人移向玩家，所以让我们添加对玩家场景的引用。### Enemy.gd extends CharacterBody2D # older code #player scene ref var player现在我们已经定义了变量，我们可以继续在内置的ready()函数中初始化随机数和玩家引用，因为我们希望这些对象在我们的敌人场景进入主场景时立即初始化。我们将把玩家引用连接到主场景中的玩家节点。由于敌人场景也将在主场景中实例化 - 因此与玩家场景共享场景树，我们可以通过 get_tree ().root.get_node 方法获取玩家。Main是我们的主场景，而/Player是我们主场景中的玩家实例。### Enemy.gd extends CharacterBody2D # Node refs @onready var player = get_tree().root.get_node("Main/Player") # Enemy stats @export var speed = 50 var direction : Vector2 # current direction var new_direction = Vector2(0,1) # next direction # Direction timer var rng = RandomNumberGenerator.new() var timer = 0 func _ready(): rng.randomize()接下来，让我们添加敌人的移动代码。敌人移动的编码过程与玩家的类似。首先，我们将添加代码来移动它们。然后，我们只需添加计时器来重定向敌人，并在他们“看到”我们时将他们移向玩家。完成这些之后，我们将在此基础上根据他们的移动方向改变他们的动画。在这一部分中，我们将添加重定向和移动，但还不添加动画。接下来，让我们添加敌人的移动代码。敌人移动的编码过程与玩家的类似。首先，我们将添加代码来移动它们。然后，我们只需添加计时器来重定向敌人，并在他们“看到”我们时将其移向玩家。完成这些事情后，我们将在此基础上根据他们的移动方向更改他们的动画。在这一部分中，我们将添加重定向和移动，但暂时不添加动画。我们还将在physics_process()函数中添加敌人的移动代码，因为我们将与节点的移动和物理相关的所有内容都放在此代码中。让我们首先通过move_and_collide方法为它们添加移动功能，就像我们为玩家所做的那样。### Enemy.gd # older code # Apply movement to the enemy func _physics_process(delta): var movement = speed * direction * delta var collision = move_and_collide(movement)现在，我们需要将 Timer 节点的timeout()信号连接到我们的脚本。当我们的计时器达到 0 时，此信号将发出。您会看到它在脚本末尾创建了一个func _on_timer_timeout():函数。在这个超时函数中，我们需要做几件事。首先，我们需要计算玩家相对于敌人的位置。我们可以通过访问节点的变换值（位置、旋转和比例）找到以 Vector(0,0) 值返回的玩家位置。了解了这一点，我们可以通过简单地说player.position来访问玩家的位置——如果我们想要他们的旋转，我们可以说player.rotation.x ，等等。我们还可以通过简单地说position或self.position来访问当前节点的位置（即我们的敌人节点）。获得玩家位置后，我们需要用敌人位置减去玩家位置，得到玩家与敌人之间的距离。让我们继续获取这个值。### Enemy.gd # older code # ------------------------- Movement & Direction --------------------- # Apply movement to the enemy func _physics_process(delta): var movement = speed * direction * delta var collision = move_and_collide(movement) func _on_timer_timeout(): # Calculate the distance of the player relative position to the enemy's position var player_distance = player.position - position现在，如果距离敌人 20 像素以内，则意味着敌人距离玩家足够近，因此无需追逐他们，而是可以继续“攻击”或“交战”玩家。您可以将此视线值设为任意数字，但我将使用 20 像素。### Enemy.gd # older code func _on_timer_timeout(): # Calculate the distance of the player relative position to the enemy's position var player_distance = player.position - position #turn towards player so that it can attack if within radius if player_distance.length() <= 20: new_direction = player_distance.normalized()如果它们距离敌人 100 像素以内，并且计时器已用完，则意味着敌人距离玩家不够近，无法攻击玩家，因此它们必须向玩家移动并开始追逐玩家。您可以将此追逐值设为任意数字，但我将使用 100 像素。### Enemy.gd # older code func _on_timer_timeout(): # Calculate the distance of the player relative position to the enemy's position var player_distance = player.position - position #turn towards player so that it can attack if within radius if player_distance.length() <= 20: new_direction = player_distance.normalized() #chase/move towards player to attack them elif player_distance.length() <= 100 and timer == 0: direction = player_distance.normalized()否则，如果玩家离敌人不近，或者不在我们的追逐半径内，那么我们的敌人就可以随意漫游。敌人的方向将通过Vector.DOWN.rotate方法随机计算，该方法将计算 0 到 360° 之间的随机角度。每次计时器超时时，此方向都会改变。### Enemy.gd # older code func _on_timer_timeout(): # Calculate the distance of the player relative position to the enemy's position var player_distance = player.position - position #turn towards player so that it can attack if within radius if player_distance.length() <= 20: new_direction = player_distance.normalized() #chase/move towards player to attack them elif player_distance.length() <= 100 and timer == 0: direction = player_distance.normalized() #random roam elif timer == 0: #this will generate a random direction value var random_direction = rng.randf() #This direction is obtained by rotating Vector2.DOWN by a random angle. if random_direction < 0.05: #enemy stops direction = Vector2.ZERO elif random_direction < 0.1: #enemy moves direction = Vector2.DOWN.rotated(rng.randf() * 2 * PI)最后，我们需要使用来自physics_process()函数的碰撞器变量来查看敌人是否与玩家发生碰撞，并添加计时器范围以进行随机化。如果它们与玩家发生碰撞，则需要将计时器设置为 0 以触发timeout()函数，这样敌人就会追逐我们。如果它们没有与我们的玩家发生碰撞，我们需要设置计时器随机器值以及随机化它们的方向旋转值，以便它们在与其他物体碰撞时可以转身。此旋转角度是使用randf_range()函数获得的。此角度的值介于 45° 到 90° 之间。如果您愿意，可以更改这些值以使其更平滑或更清晰。### Enemy.gd # older code # Apply movement to the enemy func _physics_process(delta): var movement = speed * direction * delta var collision = move_and_collide(movement) #if the enemy collides with other objects, turn them around and re-randomize the timer countdown if collision != null and collision.get_collider().name != "Player": #direction rotation direction = direction.rotated(rng.randf_range(PI/4, PI/2)) #timer countdown random range timer = rng.randf_range(2, 5) #if they collide with the player #trigger the timer's timeout() so that they can chase/move towards our player else: timer = 0如果你在主场景中实例化敌人场景，并运行它，那么他们就会追逐你或四处游荡。这样，我们就添加了一个对我们没有任何威胁的敌人。我们的敌人还没有动画或任何价值，但这些将在接下来的几部分中实现。在下一节中，我们将添加为敌人的移动添加动画的功能。幸运的是，我们已经添加了动画，因此只需快速设置即可在敌人的移动中显示这些动画！记得保存你的游戏项目，下一部分见。本节代码下载。
让我们通过制作 RPG 来学习 Godot 4 — 第 8 部分：添加弹药拾取和消耗品
如果您已成功完成本系列教程的这一部分，那么您应该已经设置好了基本地图、可移动角色和游戏 GUI。在本节中，我们将重点介绍如何将拾取物添加到我们的游戏中。这些拾取物将包括健康和耐力消耗品以及弹药。当玩家拾取这些物品时，我们还将更新我们的 GUI 值。您将在本部分中学习到的内容：· 如何使用枚举。· 如何使用@tool对象。· 如何在编辑器中执行代码。· 如何通过内存队列从场景中删除节点。· 如何使用 Area2D 检测体。· 如何引用 TileMap 属性。拾取物品设置我们不会为每种消耗品创建单独的拾取物品（即弹药拾取、体力拾取和健康拾取的场景），而是为我们的拾取物品创建一个单一的基础场景，以便在整个游戏中重复使用。我们将通过枚举和导出资源来实现这一点。在您的项目中，创建一个以Area2D节点为根的新场景。Area2D 节点用作检测碰撞的区域，换句话说，我们将使用此区域来检测我们的 Player 场景是否通过碰撞进入其主体。如果玩家已经撞到了 Pickup 场景的碰撞主体，则应移除 Pickup 并更新 UI 值。将此 Area2D 节点重命名为“Pickup”，并将场景保存在 Scenes 文件夹下。您会注意到该节点旁边有一个警告，因为就像我们的 CharacterBody2D 节点一样，它需要形状或碰撞。让我们继续向其添加 CollisionShape2D 节点来解决此警告。在选择碰撞形状之前，我们先给它一个 Sprite，这样我们就可以在给它一个碰撞边界之前看到它是什么图像。添加一个 Sprite2D 节点并从 Assets/Icons 文件夹中为其分配任何图标。现在，让我们为 CollisionShape2D 节点分配一个形状。将其设为新的 RectangleShape2D，然后拖动碰撞形状的边界以框住您的图标。此 Pickup 场景将作为我们所有拾取物的基础场景。从这里我们希望能够动态选择拾取物类型，并在玩家跑过拾取物时将其添加到玩家的库存中。为此，我们需要将脚本附加到 Pickup 场景的根节点。将其保存在您的 Scripts 文件夹下。在脚本顶部，让我们创建一个枚举，包含所有拾取物品（弹药、生命值和耐力饮料）。我们将导出此枚举，以便我们可以从检查器面板/编辑器中动态选择要放置的拾取物品类型。枚举是常量的简写，如果您想将连续的整数分配给某个常量，枚举非常有用。要创建枚举，我们使用关键字 enum，后跟括号中的常量。### Pickup.gd extends Area2D # Pickups to choose from enum Pickups { AMMO, STAMINA, HEALTH } @export var item : Pickups现在，如果我们查看检查器面板，我们可以从下拉菜单中选择 Pickup 项。接下来，我们还要选择它的图标或纹理，因为并非所有拾取物品看起来都像钻石！为此，我们将使用一些新东西，以便我们可以在编辑器中看到这些纹理的实时变化。我们将使用@tool，这是一行功能强大的代码，当将其添加到脚本顶部时，它会在编辑器中执行。您还可以决定脚本的哪些部分在编辑器中执行，哪些部分在游戏中执行，哪些部分在两者中执行。您需要在节点类扩展之前将@tool添加到脚本的顶部。### Pickup.gd @tool extends Area2D # Pickups to choose from enum Pickups { AMMO, STAMINA, HEALTH } @export var item : Pickups然后我们需要分配纹理，因此对于每个 Pickups，我们将预加载纹理。预加载是指在场景播放/运行之前加载资源。我们预加载资源以加快加载速度并减少冻结，因为资源已经加载。让我们将用于 GUI 图标的纹理分配给每个 Pickups 项目。### Pickup.gd @tool extends Area2D # Pickups to choose from enum Pickups { AMMO, STAMINA, HEALTH } @export var item : Pickups # Texture assets/resources var ammo_texture = preload("res://Assets/Icons/shard_01i.png") var stamina_texture = preload("res://Assets/Icons/potion_02b.png") var health_texture = preload("res://Assets/Icons/potion_02c.png")由于我们希望在分配新的 Pickups 时不断检查这些精灵帧是否发生变化，因此我们希望在process()函数中添加条件检查以更改这些纹理。我们在实现自定义信号时曾使用过此函数。我们希望在编辑器中执行条件，以便在无需运行游戏的情况下在编辑器中查看纹理变化。为此，我们将使用 @tool 功能，该功能需要Engine.is_editor_hint()函数在编辑器中运行代码。我们还将创建指向 Node2D 节点的节点引用。### Pickup.gd # Node refs @onready var sprite = $Sprite2D #older code # ----------------------- Icon -------------------------------------- #allow us to change the icon in the editor func _process(_delta): #executes the code in the editor without running the game if Engine.is_editor_hint(): #if we choose x item from Inspector dropdown, change the texture if item == Pickups.AMMO: sprite.set_texture(ammo_texture) elif item == Pickups.HEALTH: sprite.set_texture(health_texture) elif item == Pickups.STAMINA: sprite.set_texture(stamina_texture)现在如果你在主场景中实例化你的 Pickup 场景，并且在 Inspector 面板中更改你的 Pickup 项目，你的纹理就会改变！我们还必须在游戏中反映这些设置，因此，我们将在ready()函数中设置纹理。### Pickup.gd # Node refs @onready var sprite = $Sprite2D #older code # ----------------------- Icon -------------------------------------- func _ready(): #executes the code in the game if not Engine.is_editor_hint(): #if we choose x item from Inspector dropdown, change the texture if item == Pickups.AMMO: sprite.set_texture(ammo_texture) elif item == Pickups.HEALTH: sprite.set_texture(health_texture) elif item == Pickups.STAMINA: sprite.set_texture(stamina_texture)如果您在主场景中实例化多个 Pickup 场景并更改其项目值，然后运行场景，您现在将看到游戏中的纹理也发生了变化。使用物品拾取如果我们的玩家碰到这些拾取物，我们希望将它们从场景中移除并添加到玩家的库存中。我们将不得不再次使用一些自定义信号和函数来实现这一点，但首先，让我们专注于在玩家与场景发生碰撞时从场景中移除物品。Area2D 节点带有一个名为body_entered()的内置信号，当定义的物体进入此区域时会发出该信号。此定义的物体是任何添加了碰撞形状的物体，例如来自 Player 场景的 CharacterBody2D。将此信号连接到您的 Pickup 脚本。您将看到它在脚本末尾创建了一个新的“func _on_body_entered(body):”函数。在这个新的信号函数中，我们要检查进入我们区域的主体是否名为“Player”，如果是，我们的 Pickup 场景应该从主场景树中删除自身。当节点是场景树的一部分时，可以通过调用.get_tree()方法获取树。要从场景树中删除节点，我们可以使用queue_free() 或queue_delete()。 ### Pickup.gd #older code # -------------------- Using Pickups ------------------- func _on_body_entered(body): if body.name == "Player": #todo: adding function will come here #delete from scene tree get_tree().queue_delete(self)如果您现在运行场景并且让玩家浏览您的拾取物品，则它应该从场景中完全移除。您将在我的代码示例中看到我添加了一行“#todo：添加函数将在此处出现”。我们首先需要创建从 Player 脚本将这些物品添加到我们玩家库存中的函数，然后在 Pickup 脚本中引用它。我们的玩家库存将不再是我们可以添加许多物品和对象的传统库存，而更像是一个只能通过我们预先存在的 GUI 显示的隐形库存。换句话说，我们的库存只能包含 Pickup 物品。在您的 Player 脚本中，让我们定义更多信号来更新拾取物品的值。每当我们移除或添加拾取物品时，这些信号就会发出。### Player.gd # older code # Custom signals signal health_updated signal stamina_updated signal ammo_pickups_updated signal health_pickups_updated signal stamina_pickups_updated我们还需要再次定义我们的 Pickups 枚举，以便我们可以使用相同的常量。### Player.gd # older code # Custom signals signal health_updated signal stamina_updated signal ammo_pickups_updated signal health_pickups_updated signal stamina_pickups_updated # Pickups enum Pickups { AMMO, STAMINA, HEALTH }接下来，我们需要为每个拾取物品定义一个新变量，以便我们可以存储它们的数量。### Player.gd # older code # Pickups enum Pickups { AMMO, STAMINA, HEALTH } var ammo_pickup = 0 var health_pickup = 0 var stamina_pickup = 0最后，我们可以继续创建自定义函数，将拾取物添加到玩家的库存中（屏幕左上角的 GUI）。我们将在 Pickups 脚本中调用此函数，这样我们就可以将item变量传递到参数中，因为这是我们在 Inspector 面板中选择的。我们将执行一个条件来检查已拾取哪个物品，然后将 x 个该物品添加到我们的玩家。我们还需要发出信号来通知游戏我们的变量值已更改。### Player.gd # older code # ---------------------- Consumables -------------------- # Add the pickup to our GUI-based inventory func add_pickup(item): if item == Pickups.AMMO: ammo_pickup = ammo_pickup + 3 # + 3 bullets ammo_pickups_updated.emit(ammo_pickup) print("ammo val:" + str(ammo_pickup)) if item == Pickups.HEALTH: health_pickup = health_pickup + 1 # + 1 health drink health_pickups_updated.emit(health_pickup) print("health val:" + str(health_pickup)) if item == Pickups.STAMINA: stamina_pickup = stamina_pickup + 1 # + 1 stamina drink stamina_pickups_updated.emit(stamina_pickup) print("stamina val:" + str(stamina_pickup))现在我们可以回到我们的 Pickup 脚本并通过 body 方法引用此函数来添加该项目。### Pickup.gd # older code # -------------------- Using Pickups ------------------- func _on_body_entered(body): if body.name == "Player": body.add_pickup(item) #delete from scene tree get_tree().queue_delete(self)现在，我们可以运行我们的场景，看看当我们的玩家运行我们的 Pickup 物品时这是否有效。我们的拾取物品现在已添加到玩家的库存中，但我们尚未看到 GUI 中的变化。我们现在将添加它，以便我们的更改可以反映在游戏中而不是控制台中。在您的 Player 场景中，将一个名为 AmmoAmount、HealthAmount 和 StaminaAmount 的脚本附加到每个 UI 节点。将它们保存在您的 GUI 文件夹下。### AmmoAmount.gd extends ColorRect # Node ref @onready var value = $Value # Update ui func update_ammo_pickup_ui(ammo_pickup): value.text = str(ammo_pickup)### HealthAmount.gd extends ColorRect # Node ref @onready var value = $Value # Update ui func update_health_pickup_ui(health_pickup): value.text = str(health_pickup)### StaminaAmount.gd extends ColorRect # Node ref @onready var value = $Value # Update ui func update_stamina_pickup_ui(stamina_pickup): value.text = str(stamina_pickup)### Player.gd extends CharacterBody2D # Node references @onready var animation_sprite = $AnimatedSprite2D @onready var health_bar = $UI/HealthBar @onready var stamina_bar = $UI/StaminaBar @onready var ammo_amount = $UI/AmmoAmount @onready var stamina_amount = $UI/StaminaAmount @onready var health_amount = $UI/HealthAmount # older code func _ready(): # Connect the signals to the UI components' functions health_updated.connect(health_bar.update_health_ui) stamina_updated.connect(stamina_bar.update_stamina_ui) ammo_pickups_updated.connect(ammo_amount.update_ammo_pickup_ui) health_pickups_updated.connect(health_amount.update_health_pickup_ui) stamina_pickups_updated.connect(stamina_amount.update_stamina_pickup_ui)现在，如果您运行场景，并让玩家角色穿过拾取物，您的 GUI 应该会更新。在我们添加向玩家添加拾取物的功能的同时，我们还要继续添加一些输入，以便我们的玩家可以使用我们的健康和耐力拾取物。我们稍后会更新代码，以便使用弹药拾取物或子弹进行攻击。创建两个新的输入操作，分别称为“ui_consume_health”和“ui_consume_stamina”，并为它们分配输入键。我使用了键盘上的“1”和“2”键，但您可以分配任何您觉得合适的键。在我们的 Player 脚本中，在我们的输入（事件）函数中，让我们为这些输入操作创建新的条件。如果我们有拾取物，并且进度条上的健康或耐力值较低，我们希望在玩家按“1”或“2”饮用​​健康或耐力饮料时从拾取物数量中扣除 1。饮用这些消耗品将恢复分配的 100 分中的 50 分（请记住我们的健康和耐力变量等于 100）。在我们的健康或耐力增加并移除拾取物后，我们需要相应地发出信号。### Player.gd extends CharacterBody2D # older code func _input(event): #input event for our attacking, i.e. our shooting if event.is_action_pressed("ui_attack"): #attacking/shooting anim is_attacking = true var animation = "attack_" + returned_direction(new_direction) animation_sprite.play(animation) #using health consumables elif event.is_action_pressed("ui_consume_health"): if health > 0 && health_pickup > 0: health_pickup = health_pickup - 1 health = min(health + 50, max_health) health_updated.emit(health, max_health) health_pickups_updated.emit(health_pickup) #using stamina consumables elif event.is_action_pressed("ui_consume_stamina"): if stamina > 0 && stamina_pickup > 0: stamina_pickup = stamina_pickup - 1 stamina = min(stamina + 50, max_stamina) stamina_updated.emit(stamina, max_stamina) stamina_pickups_updated.emit(stamina_pickup)为了让我们的 UI 节点在生成时显示正确的值（例如，假设我们想在玩家生成时为玩家提供 5 发弹药而不是 0 发），我们必须在 UI 脚本中加载时更新我们的值节点。### AmmoAmount.gd extends ColorRect # Node ref @onready var value = $Value @onready var player = $"../.." # Show correct value on load func _ready(): value.text = str(player.ammo_pickup) # Update ui func update_ammo_pickup_ui(ammo_pickup): value.text = str(ammo_pickup)### HealthAmount.gd extends ColorRect # Node ref @onready var value = $Value @onready var player = $"../.." # Show correct value on load func _ready(): value.text = str(player.health_pickup) # Update ui func update_health_pickup_ui(health_pickup): value.text = str(health_pickup)### StaminaAmount.gd extends ColorRect # Node ref @onready var value = $Value @onready var player = $"../.." # Show correct value on load func _ready(): value.text = str(player.stamina_pickup) # Update ui func update_stamina_pickup_ui(stamina_pickup): value.text = str(stamina_pickup)### Pickup.gd # Pickups to choose from @export var item : Global.Pickups # ----------------------- Icon -------------------------------------- func _ready(): #executes the code in the game if not Engine.is_editor_hint(): #if we choose x item from Inspector dropdown, change the texture if item == Global.Pickups.AMMO: sprite.set_texture(ammo_texture) elif item == Global.Pickups.HEALTH: sprite.set_texture(health_texture) elif item == Global.Pickups.STAMINA: sprite.set_texture(stamina_texture) #allow us to change the icon in the editor func _process(_delta): #executes the code in the editor without running the game if Engine.is_editor_hint(): #if we choose x item from Inspector dropdown, change the texture if item == Global.Pickups.AMMO: sprite.set_texture(ammo_texture) elif item == Global.Pickups.HEALTH: sprite.set_texture(health_texture) elif item == Global.Pickups.STAMINA: sprite.set_texture(stamina_texture)现在，如果您将变量更改为具有不同的值（例如 ammo_pickup = 2）并运行场景，则应该会显示正确的值。如果您拾起耐力拾取物，冲刺，然后按“2”消耗耐力拾取物，您的耐力 GUI 值应该会更新，耐力进度条也会更新！复制物品拾取我们不必手动放置拾取物，而是创建一个随机器，在游戏运行时，它会在地图上随机生成 x 个拾取物。为此，我们将创建一个新的 Global Autoload Singleton脚本。此脚本将包含游戏中多个场景中使用的所有变量、场景资源和方法。什么是单例脚本？单例脚本是一种在游戏启动时自动加载并在游戏的整个生命周期内保持可访问的脚本。这使其成为管理需要从多个场景或脚本访问的游戏范围数据、功能和系统的理想选择。在脚本文件夹下，创建一个名为“Global.gd”的新脚本。然后，在“项目设置”>“自动加载”菜单中，将“全局”脚本指定为新的单例。这将使存储在此脚本中的变量和函数在每个脚本中都可以访问。现在，在您的主场景中，删除您实例化的任何 Pickups 场景。然后添加一个重命名为“SpawnedPickups”的 Node2D 节点。此节点将充当我们的 pickups 的组织者，即它将保存我们生成的所有 pickup 实例。现在，在您新创建的 Global 脚本中，让我们预加载Pickups 场景，以便稍后实例化。通过在 Global 脚本中预加载场景，我们可以在其他场景中实例化这些场景，而无需在每个脚本中加载它们。预加载场景可减少资源加载时间和潜在的滞后。何时使用加载，何时使用预加载？load 和 preload 都可用于在 GDScript 中加载资源。如果您知道某个资源将在 load 时使用，并且希望在脚本编译期间加载该资源，则使用 preload。如果您希望仅在满足特定条件（例如调用函数时）时加载资源，则使用 load。### Global.gd extends Node # Scene resources @onready var pickups_scene = preload("res://Scenes/Pickup.tscn")接下来，将新脚本附加到主场景。将此脚本保存在您的脚本文件夹下。现在，在我们的主脚本中，让我们创建一个指向 TileMap 节点 (Map) 以及 Map 节点上的图层的节点引用。请记住，图层的 ID 从零开始，因此water = 0, grass = 1, sand = 2, foliage = n。仅添加对您拥有的图层的引用。例如，我有 exterior_1 和 exterior_2，但您可能没有。### Main.gd extends Node2D # Node refs @onready var map = $Map # TileMap layers const WATER_LAYER = 0 const GRASS_LAYER = 1 const SAND_LAYER = 2 const FOLIAGE_LAYER = 3 const EXTERIOR_1_LAYER = 4 const EXTERIOR_2_LAYER = 5现在，让我们创建一个函数来检查 TileMap 上的给定位置是否是有效的生成位置。此函数将检查指定层（第一层或第二层）中的单元格类型，以确定它是否是生成拾取物的有效位置。我们将通过get_cell_source_id()方法检查指定层上的单元格类型，该方法检索有关 TileMap 特定层中特定单元格位置的图块的信息。get_cell_source_id 和 get_cell_tile_data 有什么区别？get_cell_source_id 和 get_cell_tile_data 都是用于查询图块地图中单元格信息的方法，但它们返回的信息类型不同。get_cell_source_id方法仅返回特定单元格的 ID，因此我们使用此方法进行简单检查，例如单元格是否为空或是否包含特定类型的图块。get_cell_tile_data方法返回有关图块的更详细信息，而不仅仅是其 ID，因此我们使用此方法获取单元格或图块的自定义数据、转换属性或状态值。如果单元格位于沙层或草地上，我们将返回其作为有效生成位置。如果单元格位于任何其他层上，则它将不是有效生成位置。这将阻止拾取物在建筑物和水上生成。### Main.gd # older code # Valid pickup spawn location func is_valid_spawn_location(layer, position): var cell_coords = Vector2(position.x, position.y) # Check if there's a tile on the water, foliage, or exterior layers if map.get_cell_source_id(WATER_LAYER, cell_coords) != -1 || map.get_cell_source_id(FOLIAGE_LAYER, cell_coords) != -1 || map.get_cell_source_id(EXTERIOR_1_LAYER, cell_coords) != -1 || map.get_cell_source_id(EXTERIOR_2_LAYER, cell_coords) != -1: return false # Check if there's a tile on the grass or sand layers if map.get_cell_source_id(GRASS_LAYER, cell_coords) != -1 || map.get_cell_source_id(SAND_LAYER, cell_coords) != -1: return true return false现在，为了生成拾取物，我们需要创建一个新函数。此函数将随机选择 TileMap 上的一个位置并检查它是否是有效的生成位置。如果是，它将在该位置实例化一个拾取物。我们将实例化我们在 Global 脚本中加载的场景。在 Godot 4 中，我们通过实例化 方法实例化场景引用。### Main.gd # older code # Spawn pickup func spawn_pickups(amount): var spawned = 0 while spawned < amount: # Randomly choose a location on the first or second layer var random_position = Vector2(randi() % map.get_used_rect().size.x, randi() % map.get_used_rect().size.y) var layer = randi() % 2 # Spawn it underneath SpawnedPickups node if is_valid_spawn_location(layer, random_position): var pickup_instance = Global.pickups_scene.instantiate() pickup_instance.position = map.map_to_local(random_position) spawned_pickups.add_child(pickup_instance) spawned += 1什么是 map_to_local？TileMap 节点的map_to_local(Vector2i map_position)方法将给定的地图位置（例如我们的玩家坐标）转换为 TileMap 的本地坐标空间。当您想在本地坐标系中找到 TileMap 中特定单元格的像素位置时，这很有用。最后，在我们的 ready 函数中，我们可以调用该函数在加载时在我们的地图上生成随机数量的拾取物。我们需要使用 RandomNumberGenerator来随机化拾取物的数量，以便每次调用该函数时它都是不同的。### Main.gd extends Node2D # Node refs @onready var map = $Map @onready var spawned_pickups = $SpawnedPickups # TileMap layers const WATER_LAYER = 0 const GRASS_LAYER = 1 const SAND_LAYER = 2 const FOLIAGE_LAYER = 3 const EXTERIOR_1_LAYER = 4 const EXTERIOR_2_LAYER = 5 var rng = RandomNumberGenerator.new() func _ready(): # Spawn between 5 and 10 pickups var spawn_pickup_amount = rng.randf_range(5, 10) spawn_pickups(spawn_pickup_amount)如果您现在运行场景，它将仅生成一种拾取物 — 弹药、生命值或耐力。我们希望它是随机的。让我们从 Player 和 Pickup 脚本中删除 Pickups 枚举，并在 Global 脚本中重新定义它。然后我们需要更新对枚举的引用。### Global.gd extends Node # Scene resources @onready var pickups_scene = preload("res://Scenes/Pickup.tscn") # Pickups enum Pickups { AMMO, STAMINA, HEALTH }### Player.gd # older code # ---------------------- Consumables ------------------------------------------ # Add the pickup to our GUI-based inventory func add_pickup(item): if item == Global.Pickups.AMMO: ammo_pickup = ammo_pickup + 3 # + 3 bullets ammo_pickups_updated.emit(ammo_pickup) print("ammo val:" + str(ammo_pickup)) if item == Global.Pickups.HEALTH: health_pickup = health_pickup + 1 # + 1 health drink health_pickups_updated.emit(health_pickup) print("health val:" + str(health_pickup)) if item == Global.Pickups.STAMINA: stamina_pickup = stamina_pickup + 1 # + 1 stamina drink stamina_pickups_updated.emit(stamina_pickup) print("stamina val:" + str(stamina_pickup))### Pickup.gd # Pickups to choose from @export var item : Global.Pickups # ----------------------- Icon -------------------------------------- func _ready(): #executes the code in the game if not Engine.is_editor_hint(): #if we choose x item from Inspector dropdown, change the texture if item == Global.Pickups.AMMO: sprite.set_texture(ammo_texture) elif item == Global.Pickups.HEALTH: sprite.set_texture(health_texture) elif item == Global.Pickups.STAMINA: sprite.set_texture(stamina_texture) #allow us to change the icon in the editor func _process(_delta): #executes the code in the editor without running the game if Engine.is_editor_hint(): #if we choose x item from Inspector dropdown, change the texture if item == Global.Pickups.AMMO: sprite.set_texture(ammo_texture) elif item == Global.Pickups.HEALTH: sprite.set_texture(health_texture) elif item == Global.Pickups.STAMINA: sprite.set_texture(stamina_texture)现在，在我们的主场景中，让我们在将拾取器类型添加到场景之前将其随机化。### Main.gd # older code # Spawn pickup func spawn_pickups(amount): var spawned = 0 while spawned < amount: # Randomly choose a location on the first or second layer var random_position = Vector2(randi() % map.get_used_rect().size.x, randi() % map.get_used_rect().size.y) var layer = randi() % 2 # Spawn it underneath SpawnedPickups node if is_valid_spawn_location(layer, random_position): var pickup_instance = Global.pickups_scene.instantiate() # Randomly select a pickup type pickup_instance.item = Global.Pickups.values()[randi() % 3] # Add pickup to scene pickup_instance.position = map.map_to_local(random_position) spawned_pickups.add_child(pickup_instance) spawned += 1此外，如果没有足够的有效生成位置，spawn_pickups 中的循环可能会无限。为了防止这种情况，您可以添加最大尝试次数：### Main.gd # older code # Spawn pickup func spawn_pickups(amount): var spawned = 0 var attempts = 0 var max_attempts = 1000 # Arbitrary number, adjust as needed while spawned < amount and attempts < max_attempts: attempts += 1 var random_position = Vector2(randi() % map.get_used_rect().size.x, randi() % map.get_used_rect().size.y) var layer = randi() % 2 if is_valid_spawn_location(layer, random_position): var pickup_instance = Global.pickups_scene.instantiate() pickup_instance.item = Global.Pickups.values()[randi() % 3] pickup_instance.position = map.map_to_local(random_position) spawned_pickups.add_child(pickup_instance) spawned += 1这将确保当有效生成位置不足时，循环不会无限期运行。如果您现在运行场景，您的拾取物应该会随机生成在地图上！在下一部分中，我们将进入游戏的有趣部分。我们将设置游戏中第一个挑战玩家的敌人。这将是一段漫长的旅程，所以记得保存你的游戏项目，下一部分见。本节代码下载。
让我们通过制作 RPG 来学习 Godot 4 — 第 7 部分：设置游戏 GUI #3
在前两部分中，我们为健康和体力值以及拾取物创建了 UI 元素。在 GUI 部分的最后一部分中，我们将创建显示玩家等级和 XP 值所需的元素。您将在本部分中学习到的内容：· 如何更轻松地向场景中添加 UI 元素。· 如何定位标签。· 如何改变节点的字体和锚定。等级和 XP UI我们暂时不会添加任何代码来更新它，因为我们首先需要为其创建一个敌人。现在让我们继续创建这些元素。在您的 Player 场景中，让我们将一个新的 ColorRect 添加到我们的 UI 节点。将此节点重命名为 XP，并向其添加两个 Label 节点。将第二个 Label 节点重命名为“Value”。选择 XP 节点后，将其 Color 属性更改为 #3a39356a，并将其变换属性更改为以下内容：将两个标签节点的字体更改为 ARCADECLASSIC，并将字体大小更改为 15。将标签的文本更改为“XP：”，将值的文本更改为“0/100”。然后，将 Label 节点的锚点预设更改为居中偏左。将其属性更改为以下内容：对 Value 节点执行相同操作，但将其更改为居中偏右。将其属性更改为以下内容：复制您的 XP 节点及其所有子节点并将其重命名为“Level”。将 Level 节点的变换属性更改为以下内容：最后，将标签的文本更改为“Level:”，将值的文本更改为“1”。你的结果应该如下所示：这就是这个游戏 GUI 的全部内容。我知道这是一个很长的三部分，但在下一部分中，我们至少可以在创建弹药和消耗品拾取物时做一些更有趣的事情！请记住保存并备份您的游戏项目，我们将在下一部分中与您见面。本节代码下载。