让我们通过制作 RPG 来学习 Godot 4 — 第 21 部分:简单的店主
在完成我们的RPG系列之前,我们不能不添加一个商店老板到我们的游戏中。我们希望玩家能够从商店老板那里购买弹药、生命值和耐力值。这意味着玩家不必总是冒着生命危险去寻找弹药和消耗品!不再多说,让我们在游戏中添加一个简单的商店老板吧!
你将在这部分学到的内容:
- 如何在Sprite2D节点中裁剪动画帧。
在创建商店老板场景之前,我们需要先给玩家一些金币——并更新我们的NPC和敌人脚本,以便在玩家完成任务或杀死敌人时给予他们金币。在你的玩家脚本中,定义一个名为“coins”的新变量,并给它一个初始值。我将给玩家200金币作为初始值。
Player.gd
# 旧代码
# 拾取物
var ammo_pickup = 13
var health_pickup = 2
var stamina_pickup = 2
var coins = 200
我们希望这个金币数量显示在UI中,所以让我们为金币数量添加一个新的UI元素。你可以复制并粘贴你的StaminaAmount元素,并将其重命名为“CoinAmount”。
将CoinAmount图标更改为“coin_04d.png”。
然后,将CoinAmount的ColorRect的变换和锚点预设属性更改为下图所示。我通过图片展示这些属性以加快速度,因为你现在应该知道如何更改这些属性了。
就像我们的其他UI组件一样,让我们定义一个新的信号,并将脚本附加到我们的CoinAmount节点上。
Player.gd
# 旧代码
# 自定义信号
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
signal coins_updated
在我们的CoinAmount脚本中,让我们创建一个函数来根据金币数量更新UI值。然后,在玩家脚本中,我们将把这个函数连接到我们的信号。
CoinAmount.gd
extends ColorRect
# 节点引用
@onready var value = $Value
@onready var player = $"../.."
# 加载时显示正确的值
func _ready():
value.text = str(player.coins)
# 更新UI
func update_coin_amount_ui(coin_amount):
value.text = str(coin_amount)
Player.gd
extends CharacterBody2D
# 节点引用
@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 coin_amount = $UI/CoinAmount
# 旧代码
func _ready():
# 将信号连接到UI组件的函数
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)
coins_updated.connect(coin_amount.update_coin_amount_ui)
# 重置颜色
animation_sprite.modulate = Color(1,1,1,1)
Input.set_mouse_mode(Input.MOUSE_MODE_HIDDEN)
接下来,我们将在玩家脚本中创建一个新函数,每当金币数量发生变化时发出信号。
Player.gd
# ---------------------- 消耗品 ------------------------------------------
# 旧代码
# 添加金币到库存
func add_coins(coins_amount):
coins += coins_amount
coins_updated.emit(coins)
然后,在我们的NPC和敌人脚本中,我们将在完成任务或杀死敌人时调用此函数。我们将传入我们想要奖励玩家的金币数量作为参数。
Enemy.gd
# 当敌人受到攻击时会受到伤害
func hit(damage):
health -= damage
if health > 0:
# 伤害
animation_player.play("damage")
else:
# 死亡
# 停止移动
timer_node.stop()
direction = Vector2.ZERO
# 停止生命值恢复
set_process(false)
# 触发动画完成信号
is_attacking = true
# 最后,播放死亡动画
animation_sprite.play("death")
# 添加经验值
player.update_xp(70)
player.add_coins(10)
death.emit()
# 以90%的几率随机掉落战利品
if rng.randf() < 0.9:
var pickup = Global.pickups_scene.instantiate()
pickup.item = rng.randi() % 3 # 我们的枚举中有三个拾取物
get_tree().root.get_node("%s/PickupSpawner/SpawnedPickups" % Global.current_scene_name).call_deferred("add_child", pickup)
pickup.position = position
NPC.gd
# 对话树
func dialog(response = ""):
# 将NPC的动画设置为“talk”
animation_sprite.play("talk_down")
# 设置dialog_popup的npc引用为此npc
dialog_popup.npc = self
dialog_popup.npc_name = str(npc_name)
# 对话树
match quest_status:
QuestStatus.NOT_STARTED:
match dialog_state:
# 旧代码
QuestStatus.STARTED:
match dialog_state:
0:
# 更新对话树状态
dialog_state = 1
# 显示对话弹出窗口
dialog_popup.message = "找到那本书了吗?"
if quest_complete:
dialog_popup.response = "[A] 是 [B] 否"
else:
dialog_popup.response = "[A] 否"
dialog_popup.open()
1:
if quest_complete and response == "A":
# 更新对话树状态
dialog_state = 2
# 显示对话弹出窗口
dialog_popup.message = "耶!现在我可以做猫眼汤了。给你,拿着这个。"
dialog_popup.response = "[A] 再见"
dialog_popup.open()
else:
# 更新对话树状态
dialog_state = 3
# 显示对话弹出窗口
dialog_popup.message = "我太饿了,请快点..."
dialog_popup.response = "[A] 再见"
dialog_popup.open()
2:
# 更新对话树状态
dialog_state = 0
quest_status = QuestStatus.COMPLETED
# 关闭对话弹出窗口
dialog_popup.close()
# 将NPC的动画设置回“idle”
animation_sprite.play("idle_down")
# 给玩家添加拾取物和经验值
player.add_pickup(Global.Pickups.AMMO)
player.update_xp(50)
player.add_coins(20)
别忘了在你的玩家脚本中保存和加载金币数据。
Player.gd
#-------------------------------- 保存与加载 -----------------------
# 要保存的数据
func data_to_save():
return {
"position" : [position.x, position.y],
"health" : health,
"max_health" : max_health,
"stamina" : stamina,
"max_stamina" : max_stamina,
"xp" : xp,
"xp_requirements" : xp_requirements,
"level" : level,
"ammo_pickup" : ammo_pickup,
"health_pickup" : health_pickup,
"stamina_pickup" : stamina_pickup,
"coins" : coins
}
# 从保存的数据中加载数据
func data_to_load(data):
position = Vector2(data.position[0], data.position[1])
health = data.health
max_health = data.max_health
stamina = data.stamina
max_stamina = data.max_stamina
xp = data.xp
xp_requirements = data.xp_requirements
level = data.level
ammo_pickup = data.ammo_pickup
health_pickup = data.health_pickup
stamina_pickup = data.stamina_pickup
coins = data.coins
# 从保存的数据中加载数据
func values_to_load(data):
health = data.health
max_health = data.max_health
stamina = data.stamina
max_stamina = data.max_stamina
xp = data.xp
xp_requirements = data.xp_requirements
level = data.level
ammo_pickup = data.ammo_pickup
health_pickup = data.health_pickup
stamina_pickup = data.stamina_pickup
coins = data.coins
# 更新UI组件以显示正确的加载数据
$UI/AmmoAmount/Value.text = str(data.ammo_pickup)
$UI/StaminaAmount/Value.text = str(data.stamina_pickup)
$UI/HealthAmount/Value.text = str(data.health_pickup)
$UI/XP/Value.text = str(data.xp)
$UI/XP/Value2.text = "/ " + str(data.xp_requirements)
$UI/Level/Value.text = str(data.level)
$UI/CoinAmount/Value.text = str(data.coins)
如果你现在运行场景并杀死敌人或完成任务,你的金币数量应该会更新!
有了玩家的金币设置,我们可以继续创建我们的商店老板。让我们创建一个以Node2D节点为根的新场景。我们使用这个节点是因为我们不会移动这个角色,所以CharacterBody2D节点将是多余的。将此根重命名为“ShopKeeper”并将场景保存在你的Scenes文件夹中。同时,附加一个脚本并将其保存在你的Scripts文件夹中。
对于这个节点,我们希望有一个简单的Sprite2D来显示商店老板的身体。在这个身体前面,我们希望有一个Area2D节点,如果玩家进入它的身体,将显示ShopMenu CanvasLayer。ShopMenu弹出窗口将包含玩家可以以特定价格购买的拾取物列表。让我们添加以下节点:
在你的Assets目录中,有一个名为“NPC”的文件夹。将“NPC’s.png”图像分配给你的Sprite2D节点。
我们希望裁剪出第二行中的第一个人(拿着啤酒的男人)。为此,我们需要在Inspector面板中的Animations属性中更改HFrames、VFrames和Frames值。HFrames指的是水平帧。我们可以数出3帧,因为每行有3个人,所以它的值应该是3。VFrames也是如此。然后我们只需更改Frames值,直到我们找到拿着啤酒的男人!
然后,让我们为Area2D节点添加一个矩形碰撞形状,并将其移动到商店老板的前面。
现在,工作来了!UI创建始终是游戏开发中最繁琐的部分——至少对我来说是这样。对于我们的ShopMenu,我们希望有三个ColorRects来显示弹药、生命值和耐力值的图标、标签和购买按钮。如果我们有一个动态库存(一个会改变物品类型的库存),我们会通过列表和盒子来实现这一点,但由于我们有一个静态库存(一个不会改变的库存),它只由3个物品组成,我们将直接为每个物品添加一个ColorRect、Label、Sprite2D和Button节点。
添加以下节点(ColorRect > Label 和 3 x ColorRect > Sprite2D > Label > Button)并按照下图所示重命名它们。
然后,将第一个ColorRect的颜色更改为#581929,并将其锚点预设更改为“Full-Rect”。
然后,将Label节点的文本更改为“SHOP”。将其字体大小更改为20,字体为“Schrödinger”,字体颜色为#2a0810。将其变换和预设值更改为与下图匹配。
将Ammo ColorRect的颜色更改为#3f0f1b。将其变换和预设值更改为与下图匹配。你可以对Health和Stamina ColorRects执行相同的操作。
然后,将你的图标更改为你在玩家UI中选择的弹药图标。将其变换和预设值更改为与下图匹配。你可以对Health和Stamina图标执行相同的操作。
将Ammo的Label字体更改为“Schrödinger”,大小为10,字体颜色为#f2a6b2。将其变换、文本和预设值更改为与下图匹配。你可以对Health和Stamina标签执行相同的操作。
接下来,将PurchaseAmmo按钮的字体更改为“Schrödinger”,大小为10,字体颜色为#77253a。将其变换、文本和预设值更改为与下图匹配。你可以对HealthPurchase和StaminaPurchase按钮执行相同的操作。
现在,对于我们的主ColorRect,我们需要添加三个新节点:Sprite2D、Label和Button。Sprite2D和Label将显示我们剩余的金币值,Button将允许我们关闭弹出窗口。
将它们的值更改为与下图匹配。Close节点是Button,CoinAmount节点是Label(将其字体颜色更改为#2a0810,字体为“Schrödinger”),Icon是我们的金币Sprite2D(选择“coin_03d.png”作为其纹理)。
你的ShopMenu弹出窗口的最终UI应该如下所示:
现在,将每个Button的pressed()信号连接到你的脚本。如果我们按下这些按钮,我们将为每个拾取物购买,并且我们的金币数量应该更新。我们的关闭按钮应该隐藏弹出窗口并取消暂停游戏。
同时,将你的Area2D节点的body_entered()信号连接到你的脚本。我们将使用它来显示弹出窗口并暂停游戏。
我们首先需要获取玩家节点的引用,因为我们想要更新和检查他们的金币数量,并调用他们的add_pickup()函数。我们还将在process()函数中更新弹出窗口中返回的金币值。在ready()函数中,我们将初始化玩家引用并隐藏屏幕,以确保在游戏加载时商店老板进入主场景时它是隐藏的。
ShopKeeper.gd
extends Node2D
@onready var player = get_tree().root.get_node("Main/Player")
@onready var shop_menu = $ShopMenu
# 玩家引用
func _ready():
shop_menu.visible = false
# 更新金币数量
func _process(delta):
$ShopMenu/ColorRect/CoinAmount.text = "Coins: " + str(player.coins)
然后,我们将打开和关闭我们的“弹出窗口”。记得默认将节点的可见性设置为隐藏。
ShopKeeper.gd
extends Node2D
@onready var player = get_tree().root.get_node("Main/Player")
@onready var shop_menu = $ShopMenu
# 玩家引用
func _ready():
shop_menu.visible = false
# 更新金币数量
func _process(delta):
$ShopMenu/ColorRect/CoinAmount.text = "Coins: " + str(player.coins)
func _on_close_pressed():
shop_menu.visible = false
get_tree().paused = false
set_process_input(false)
player.set_physics_process(true)
func _on_area_2d_body_entered(body):
if body.is_in_group("player"):
shop_menu.visible = true
get_tree().paused = true
set_process_input(true)
player.set_physics_process(false)
我们还可以在Area2D节点的body_exited信号中隐藏我们的菜单,这将确保如果我们不在Area2D体内,菜单将被禁用。同时显示/隐藏你的光标。
ShopKeeper.gd
extends Node2D
@onready var player = get_tree().root.get_node("Main/Player")
@onready var shop_menu = $ShopMenu
func _ready():
shop_menu.visible = false
# 更新金币数量
func _process(delta):
$ShopMenu/ColorRect/CoinAmount.text = "Coins: " + str(player.coins)
# 显示菜单
func _on_area_2d_body_entered(body):
if body.is_in_group("player"):
shop_menu.visible = true
get_tree().paused = true
set_process_input(true)
player.set_physics_process(false)
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
# 关闭菜单
func _on_close_pressed():
shop_menu.visible = false
get_tree().paused = false
set_process_input(false)
player.set_physics_process(true)
Input.set_mouse_mode(Input.MOUSE_MODE_HIDDEN)
func _on_area_2d_body_exited(body):
if body.is_in_group("player"):
shop_menu.visible = false
get_tree().paused = false
set_process_input(false)
player.set_physics_process(true)
Input.set_mouse_mode(Input.MOUSE_MODE_HIDDEN)
最后,我们需要仅在玩家有足够金币时购买拾取物。你可以将此值设置为任何值,或者你可以为每个拾取物定义一个变量,而不是像我一样将其设置为常量值。
ShopKeeper.gd
extends Node2D
@onready var player = get_tree().root.get_node("Main/Player")
@onready var shop_menu = $ShopMenu
func _ready():
shop_menu.visible = false
# 更新金币数量
func _process(delta):
$ShopMenu/ColorRect/CoinAmount.text = "金币: " + str(player.coins)
# 花费10金币购买弹药
func _on_purchase_ammo_pressed():
if player.coins >= 10:
player.add_pickup(Global.Pickups.AMMO)
player.coins -= 10
player.add_coins(player.coins)
# 花费5金币购买生命值
func _on_purchase_health_pressed():
if player.coins >= 5:
player.add_pickup(Global.Pickups.HEALTH)
player.coins -= 5
player.add_coins(player.coins)
# 花费2金币购买耐力
func _on_purchase_stamina_pressed():
if player.coins >= 2:
player.add_pickup(Global.Pickups.STAMINA)
player.coins -= 2
player.add_coins(player.coins)
我们需要做的最后一件事是将 ShopKeeper
的处理模式更改为 Always
,因为当游戏暂停时,它的弹出菜单必须显示出来,但 Area2D
节点必须在游戏未暂停时触发信号。
我们还需要将 ShopMenu
的图层属性更改为 2 或更高。这将使菜单显示在玩家的 UI 之上,因为它位于更高的 z-index 上。z-index 决定了当多个元素占据同一空间时,哪个元素显示在“顶部”。z-index 值较高的元素会渲染在 z-index 值较低的元素之上。
在 Main
场景中实例化你的 ShopKeeper
。现在如果你运行场景,并且碰到 ShopKeeper
,你的菜单应该会显示出来,并且你应该能够购买一些道具。如果你关闭弹出窗口,这些值应该会传递到玩家的 HUD 中。杀死敌人和完成任务也应该会增加你的金币数量!
实现商店系统的方法有很多,其中许多方法比这个更好,但这是对我们游戏来说最简单的方式。在下一部分中,我们将为游戏添加音乐和音效。记得保存你的项目,我们下一部分见!