第十五部分:商店系统基础:ShopItem、购买/出售、金币扣除、库存、商人 NPC
这一部分开始做 RPG 里非常常见的商店系统。
前面我们已经有:
背包 Inventory
金币 gold
物品 ItemData
NPC 交互
任务奖励
药水使用
现在要把它们连起来:
玩家和商人 NPC 对话
↓
打开商店 UI
↓
显示商品列表
↓
玩家点击购买
↓
检查金币是否足够
↓
扣金币
↓
物品加入背包
↓
UI 刷新
这就是最基础的商店闭环。
1. 商店系统第一阶段要做什么
第一版先做:
商人 NPC
商店商品列表
购买物品
扣除金币
加入背包
显示玩家金币
关闭商店
先不要做:
复杂库存刷新
价格浮动
声望折扣
商人好感度
限购
买卖税率
装备预览
拖拽购买
多货币系统
第一版只要能实现:
花金币买药水
就已经很好了。
2. 商店系统由哪些部分组成
建议拆成这些:
ShopItemData:商品数据
ShopData:商店数据
ShopNPC:商人 NPC
ShopPanel:商店 UI
Player:提供金币和背包
Inventory:接收购买到的物品
职责分别是:
ShopItemData:
某个商品卖什么、多少钱、库存多少
ShopData:
一个商店有哪些商品
ShopNPC:
玩家和它交互时打开商店
ShopPanel:
显示商品和购买按钮
Player:
扣金币、加物品
Inventory:
保存买到的物品
和前面一样,核心原则是:
数据归数据
UI 归 UI
玩家资源归 Player
商店交互归 ShopNPC
不要把所有东西都塞进 NPC。
3. ShopItemData 是什么
商店里的一个商品,需要知道:
卖什么物品
价格是多少
库存是多少
是否无限库存
比如:
小型治疗药水
价格:10 金币
库存:无限
或者:
铁剑
价格:100 金币
库存:1
所以可以做一个 Resource:
ShopItemData.gd
4. ShopItemData.gd
创建:
res://scripts/shops/shop_item_data.gd
class_name ShopItemData
extends Resource
@export var item: ItemData
@export var price: int = 1
@export var stock: int = 1
@export var infinite_stock: bool = true
字段解释:
item:
这个商品对应的物品数据
price:
购买一个需要多少金币
stock:
库存数量
infinite_stock:
是否无限库存
如果 infinite_stock = true,就忽略 stock。
如果 infinite_stock = false,每买一次库存减少。
5. ShopData 是什么
一个商店通常有多个商品。
比如药水商人:
小型治疗药水
中型治疗药水
解毒草
武器商人:
木剑
铁剑
短弓
盾牌
所以商店本身也可以是一个 Resource。
创建:
res://scripts/shops/shop_data.gd
class_name ShopData
extends Resource
@export var id: String = ""
@export var display_name: String = ""
@export var items: Array[ShopItemData] = []
这样你可以创建:
potion_shop.tres
weapon_shop.tres
village_general_store.tres
6. 创建药水商店数据
先创建几个资源:
res://data/shops/shop_items/small_potion_shop_item.tres
res://data/shops/potion_shop.tres
small_potion_shop_item.tres:
item = small_potion.tres
price = 10
stock = 99
infinite_stock = true
potion_shop.tres:
id = potion_shop
display_name = 药水商店
items = [small_potion_shop_item.tres]
这样商店数据就准备好了。
7. ShopNPC 是什么
商人 NPC 本质上还是一个可交互对象。
结构可以是:
ShopNPC Area2D
├── Sprite2D
└── CollisionShape2D
加入 group:
interactable
它和普通 NPC 的区别是:
普通 NPC interact 后打开 DialogueBox
商人 NPC interact 后打开 ShopPanel
8. ShopNPC.gd
创建:
res://scripts/shops/shop_npc.gd
extends Area2D
@export var prompt_text: String = "按 E 交易"
@export var shop_data: ShopData
@export var shop_panel: Control
func interact(player: Node):
if shop_data == null:
print("ShopNPC 没有设置 shop_data")
return
if shop_panel == null:
print("ShopNPC 没有设置 shop_panel")
return
if not shop_panel.has_method("open_shop"):
print("ShopPanel 没有 open_shop 方法")
return
shop_panel.open_shop(shop_data, player)
现在玩家靠近商人按 E,就可以打开商店。
9. ShopPanel UI 结构
在 UI CanvasLayer 下添加:
ShopPanel Control
└── Panel
├── TitleLabel Label
├── GoldLabel Label
├── ItemList VBoxContainer
└── CloseButton Button
默认隐藏:
visible = false
结构:
UI CanvasLayer
├── PlayerHUD
├── InventoryPanel
├── QuestPanel
├── ShopPanel
│ └── Panel
│ ├── TitleLabel
│ ├── GoldLabel
│ ├── ItemList
│ └── CloseButton
├── InteractLabel
├── DialogueBox
└── DeathPanel
10. ShopPanel 第一版要显示什么
先显示:
商店名字
玩家金币
商品按钮
关闭按钮
商品按钮可以写成:
小型治疗药水 - 10 金币
点击按钮后购买。
第一版不用图标,不用数量选择,不用确认弹窗。
先做:
点击一次买一个
简单、直接、好调试。
11. ShopPanel.gd 基础版
创建:
res://scripts/shops/shop_panel.gd
extends Control
@onready var title_label: Label = $Panel/TitleLabel
@onready var gold_label: Label = $Panel/GoldLabel
@onready var item_list: VBoxContainer = $Panel/ItemList
@onready var close_button: Button = $Panel/CloseButton
var current_shop: ShopData
var current_player: Node
func _ready():
visible = false
close_button.pressed.connect(close_shop)
func open_shop(shop_data: ShopData, player: Node):
current_shop = shop_data
current_player = player
visible = true
refresh()
func close_shop():
visible = false
current_shop = null
current_player = null
func refresh():
clear_item_list()
if current_shop == null:
return
title_label.text = current_shop.display_name
update_gold_label()
for shop_item in current_shop.items:
add_shop_item_button(shop_item)
func clear_item_list():
for child in item_list.get_children():
child.queue_free()
func update_gold_label():
if current_player == null:
gold_label.text = "金币:0"
return
if "gold" in current_player:
gold_label.text = "金币:%d" % current_player.gold
else:
gold_label.text = "金币:?"
这部分只负责打开、关闭、刷新标题和金币。
接下来加商品按钮。
12. 添加商品按钮
继续写:
func add_shop_item_button(shop_item: ShopItemData):
if shop_item == null or shop_item.item == null:
return
var button = Button.new()
var stock_text = ""
if not shop_item.infinite_stock:
stock_text = " 库存:%d" % shop_item.stock
button.text = "%s - %d 金币%s" % [
shop_item.item.display_name,
shop_item.price,
stock_text
]
button.disabled = not can_buy(shop_item)
button.pressed.connect(func():
buy_item(shop_item)
)
item_list.add_child(button)
这里做了几件事:
创建一个 Button
设置按钮文本
如果库存有限,显示库存
如果买不起或没库存,按钮禁用
点击按钮时调用 buy_item()
13. can_buy()
func can_buy(shop_item: ShopItemData) -> bool:
if current_player == null:
return false
if shop_item == null or shop_item.item == null:
return false
if not shop_item.infinite_stock and shop_item.stock <= 0:
return false
if not "gold" in current_player:
return false
return current_player.gold >= shop_item.price
购买条件:
有玩家
商品有效
库存足够
玩家有金币字段
金币大于等于价格
14. buy_item()
购买时要:
检查能不能买
扣金币
加入背包
减少库存
刷新 UI
先写:
func buy_item(shop_item: ShopItemData):
if not can_buy(shop_item):
print("无法购买")
return
if not current_player.has_method("spend_gold"):
print("Player 没有 spend_gold 方法")
return
if not current_player.has_method("pick_up_item"):
print("Player 没有 pick_up_item 方法")
return
var paid = current_player.spend_gold(shop_item.price)
if not paid:
print("金币不足")
return
var added = current_player.pick_up_item(shop_item.item, 1)
if not added:
current_player.add_gold(shop_item.price)
print("背包添加失败,退回金币")
return
if not shop_item.infinite_stock:
shop_item.stock -= 1
refresh()
这里有个细节:
如果扣了金币,但物品加入失败,需要退回金币
虽然当前背包无限容量,基本不会失败,但这个习惯很好。
15. Player 添加 spend_gold()
Player 之前有:
func add_gold(amount: int):
gold += amount
gold_changed.emit(gold)
现在加:
func spend_gold(amount: int) -> bool:
if amount <= 0:
return false
if gold < amount:
return false
gold -= amount
gold_changed.emit(gold)
return true
购买时就调用:
player.spend_gold(price)
16. ShopPanel.gd 完整基础版
extends Control
@onready var title_label: Label = $Panel/TitleLabel
@onready var gold_label: Label = $Panel/GoldLabel
@onready var item_list: VBoxContainer = $Panel/ItemList
@onready var close_button: Button = $Panel/CloseButton
var current_shop: ShopData
var current_player: Node
func _ready():
visible = false
close_button.pressed.connect(close_shop)
func open_shop(shop_data: ShopData, player: Node):
current_shop = shop_data
current_player = player
visible = true
refresh()
func close_shop():
visible = false
current_shop = null
current_player = null
func refresh():
clear_item_list()
if current_shop == null:
return
title_label.text = current_shop.display_name
update_gold_label()
for shop_item in current_shop.items:
add_shop_item_button(shop_item)
func clear_item_list():
for child in item_list.get_children():
child.queue_free()
func update_gold_label():
if current_player == null:
gold_label.text = "金币:0"
return
if "gold" in current_player:
gold_label.text = "金币:%d" % current_player.gold
else:
gold_label.text = "金币:?"
func add_shop_item_button(shop_item: ShopItemData):
if shop_item == null or shop_item.item == null:
return
var button = Button.new()
var stock_text = ""
if not shop_item.infinite_stock:
stock_text = " 库存:%d" % shop_item.stock
button.text = "%s - %d 金币%s" % [
shop_item.item.display_name,
shop_item.price,
stock_text
]
button.disabled = not can_buy(shop_item)
button.pressed.connect(func():
buy_item(shop_item)
)
item_list.add_child(button)
func can_buy(shop_item: ShopItemData) -> bool:
if current_player == null:
return false
if shop_item == null or shop_item.item == null:
return false
if not shop_item.infinite_stock and shop_item.stock <= 0:
return false
if not "gold" in current_player:
return false
return current_player.gold >= shop_item.price
func buy_item(shop_item: ShopItemData):
if not can_buy(shop_item):
print("无法购买")
return
if not current_player.has_method("spend_gold"):
print("Player 没有 spend_gold 方法")
return
if not current_player.has_method("pick_up_item"):
print("Player 没有 pick_up_item 方法")
return
var paid = current_player.spend_gold(shop_item.price)
if not paid:
print("金币不足")
return
var added = current_player.pick_up_item(shop_item.item, 1)
if not added:
current_player.add_gold(shop_item.price)
print("背包添加失败,退回金币")
return
if not shop_item.infinite_stock:
shop_item.stock -= 1
refresh()
这已经是一个可用的购买系统。
17. 打开商店时要锁住玩家控制
打开商店时,玩家一般不能继续移动、攻击。
你可以让 Player 有一个 SHOP 或 MENU 状态。
如果你之前的 PlayerState 是:
enum PlayerState {
IDLE,
WALK,
ATTACK,
DIALOGUE,
HURT,
DEAD
}
现在可以加:
MENU
变成:
enum PlayerState {
IDLE,
WALK,
ATTACK,
DIALOGUE,
MENU,
HURT,
DEAD
}
然后:
func lock_control_for_menu():
change_state(PlayerState.MENU)
func unlock_control_from_menu():
change_state(PlayerState.IDLE)
状态逻辑:
func physics_menu(delta):
velocity = Vector2.ZERO
move_and_slide()
update_animation(Vector2.ZERO)
_physics_process 里加:
PlayerState.MENU:
physics_menu(delta)
输入里:
PlayerState.MENU:
pass
这样商店打开时,Player 不响应移动和攻击。
18. ShopPanel 打开/关闭时锁定玩家
在 ShopPanel.gd 里:
func open_shop(shop_data: ShopData, player: Node):
current_shop = shop_data
current_player = player
if current_player != null and current_player.has_method("lock_control_for_menu"):
current_player.lock_control_for_menu()
visible = true
refresh()
关闭时:
func close_shop():
if current_player != null and current_player.has_method("unlock_control_from_menu"):
current_player.unlock_control_from_menu()
visible = false
current_shop = null
current_player = null
这样打开商店后玩家不能乱跑。
19. 关闭商店的输入
除了点 CloseButton,也可以按 Esc 关闭。
InputMap 添加:
ui_cancel
Godot 默认通常已经有 ui_cancel,但你也可以自己确认绑定 Esc。
ShopPanel.gd:
func _unhandled_input(event):
if not visible:
return
if event.is_action_pressed("ui_cancel"):
close_shop()
get_viewport().set_input_as_handled()
这样打开商店时按 Esc 就能关闭。
20. 商店和对话框是否可以同时打开
不建议。
打开商店时应该:
隐藏交互提示
关闭对话框或不打开对话框
锁定玩家控制
商人 NPC 第一版直接打开 ShopPanel,不走 DialogueBox。
以后你可以做成:
商人:欢迎光临,要买点什么吗?
选项:
1. 购买
2. 离开
这需要对话选项系统。
现在先别做。
第一版:
靠近商人
按 E
直接打开商店
很干净。
21. 出售系统要不要第一版做
可以做一个简单版。
出售流程:
打开商店
切到出售模式
显示玩家背包物品
点击出售
玩家获得金币
背包数量减少
出售价格一般是购买价的一半。
但注意:玩家背包里的物品不一定在商店中有售价。
所以 ItemData 可以加:
@export var sell_price: int = 0
在 ItemData.gd 里:
@export var sell_price: int = 0
这样每个物品自己定义出售价格。
比如:
small_potion:
buy price 10
sell_price 5
slime_gel:
sell_price 2
22. ShopPanel 增加模式
可以加:
enum ShopMode {
BUY,
SELL
}
var mode: ShopMode = ShopMode.BUY
UI 结构加两个按钮:
BuyTabButton Button
SellTabButton Button
结构变成:
ShopPanel
└── Panel
├── TitleLabel
├── GoldLabel
├── Tabs HBoxContainer
│ ├── BuyTabButton
│ └── SellTabButton
├── ItemList VBoxContainer
└── CloseButton
23. 切换购买/出售模式
ShopPanel.gd:
@onready var buy_tab_button: Button = $Panel/Tabs/BuyTabButton
@onready var sell_tab_button: Button = $Panel/Tabs/SellTabButton
enum ShopMode {
BUY,
SELL
}
var mode: ShopMode = ShopMode.BUY
func _ready():
visible = false
close_button.pressed.connect(close_shop)
buy_tab_button.pressed.connect(show_buy_mode)
sell_tab_button.pressed.connect(show_sell_mode)
func show_buy_mode():
mode = ShopMode.BUY
refresh()
func show_sell_mode():
mode = ShopMode.SELL
refresh()
refresh 改成:
func refresh():
clear_item_list()
if current_shop == null:
return
title_label.text = current_shop.display_name
update_gold_label()
match mode:
ShopMode.BUY:
refresh_buy_items()
ShopMode.SELL:
refresh_sell_items()
24. refresh_buy_items()
func refresh_buy_items():
for shop_item in current_shop.items:
add_shop_item_button(shop_item)
原来的购买按钮逻辑保留。
25. refresh_sell_items()
出售显示玩家背包里的物品:
func refresh_sell_items():
if current_player == null:
return
if not current_player.has_method("get_inventory"):
return
var inventory: Inventory = current_player.get_inventory()
for slot in inventory.slots:
add_sell_item_button(slot)
26. add_sell_item_button()
func add_sell_item_button(slot: InventorySlot):
if slot == null or slot.item == null:
return
var button = Button.new()
var sell_price = slot.item.sell_price
button.text = "%s x%d - 出售 %d 金币" % [
slot.item.display_name,
slot.quantity,
sell_price
]
button.disabled = sell_price <= 0
var item_id = slot.item.id
button.pressed.connect(func():
sell_item(item_id)
)
item_list.add_child(button)
27. sell_item()
func sell_item(item_id: String):
if current_player == null:
return
if not current_player.has_method("get_inventory"):
return
var inventory: Inventory = current_player.get_inventory()
var slot = inventory.find_slot_by_item_id(item_id)
if slot == null or slot.item == null:
return
var sell_price = slot.item.sell_price
if sell_price <= 0:
print("这个物品不能出售")
return
var removed = inventory.remove_item(item_id, 1)
if not removed:
return
if current_player.has_method("add_gold"):
current_player.add_gold(sell_price)
refresh()
这样点击一个物品按钮,就出售一个。
28. 出售系统要小心任务物品
有些物品不能卖,比如:
任务物品
关键道具
装备中的武器
第一版可以简单判断:
if slot.item.item_type == ItemData.ItemType.QUEST:
button.disabled = true
或者让 sell_price = 0 表示不能出售。
更推荐后面加:
@export var sellable: bool = true
现在先用 sell_price <= 0 禁止出售。
29. ItemData 加 sell_price
ItemData.gd 增加:
@export var sell_price: int = 0
完整相关字段:
@export var heal_amount: int = 0
@export var gold_value: int = 0
@export var sell_price: int = 0
这样:
small_potion.tres sell_price = 5
slime_gel.tres sell_price = 2
quest_item.tres sell_price = 0
30. ShopPanel 购买 + 出售关键整合版
这里只放核心新增/修改部分。
enum ShopMode {
BUY,
SELL
}
var mode: ShopMode = ShopMode.BUY
@onready var buy_tab_button: Button = $Panel/Tabs/BuyTabButton
@onready var sell_tab_button: Button = $Panel/Tabs/SellTabButton
func _ready():
visible = false
close_button.pressed.connect(close_shop)
buy_tab_button.pressed.connect(show_buy_mode)
sell_tab_button.pressed.connect(show_sell_mode)
func open_shop(shop_data: ShopData, player: Node):
current_shop = shop_data
current_player = player
mode = ShopMode.BUY
if current_player != null and current_player.has_method("lock_control_for_menu"):
current_player.lock_control_for_menu()
visible = true
refresh()
func show_buy_mode():
mode = ShopMode.BUY
refresh()
func show_sell_mode():
mode = ShopMode.SELL
refresh()
func refresh():
clear_item_list()
if current_shop == null:
return
title_label.text = current_shop.display_name
update_gold_label()
match mode:
ShopMode.BUY:
refresh_buy_items()
ShopMode.SELL:
refresh_sell_items()
func refresh_buy_items():
for shop_item in current_shop.items:
add_shop_item_button(shop_item)
func refresh_sell_items():
if current_player == null:
return
if not current_player.has_method("get_inventory"):
return
var inventory: Inventory = current_player.get_inventory()
for slot in inventory.slots:
add_sell_item_button(slot)
func add_sell_item_button(slot: InventorySlot):
if slot == null or slot.item == null:
return
var button = Button.new()
var sell_price = slot.item.sell_price
button.text = "%s x%d - 出售 %d 金币" % [
slot.item.display_name,
slot.quantity,
sell_price
]
button.disabled = sell_price <= 0
var item_id = slot.item.id
button.pressed.connect(func():
sell_item(item_id)
)
item_list.add_child(button)
func sell_item(item_id: String):
if current_player == null:
return
if not current_player.has_method("get_inventory"):
return
var inventory: Inventory = current_player.get_inventory()
var slot = inventory.find_slot_by_item_id(item_id)
if slot == null or slot.item == null:
return
var sell_price = slot.item.sell_price
if sell_price <= 0:
print("这个物品不能出售")
return
var removed = inventory.remove_item(item_id, 1)
if not removed:
return
if current_player.has_method("add_gold"):
current_player.add_gold(sell_price)
refresh()
31. 商店库存会被永久修改吗
这里有个很重要的点。
ShopItemData 是 Resource。
如果你在运行中修改:
shop_item.stock -= 1
它修改的是这个 Resource 实例的内存数据。
在运行时一般没问题。
但是你要知道:
如果多个商店共用同一个 ShopItemData 资源
一个商店买掉库存,另一个商店也会受影响
因为它们引用的是同一个资源。
所以如果你想每个商店独立库存,要么:
每个商店使用自己的 ShopItemData 资源
要么运行时复制一份库存数据。
第一版建议:
每个商店单独配置自己的 ShopItemData
简单省事。
32. 无限库存最适合第一版
药水、基础材料商店,第一版建议:
infinite_stock = true
这样不用考虑库存保存。
有限库存可以等后面再做。
因为有限库存涉及:
买完后存档
刷新库存
每天补货
不同商店独立库存
这些都是后面的系统。
第一版先让玩家能买药水,就很香了。
33. 商店 UI 是否要显示物品描述
可以加,但第一版不是必须。
如果要加,在 ShopPanel 加:
DescriptionLabel Label 或 RichTextLabel
当按钮获得焦点或点击时显示:
小型治疗药水
恢复 30 点生命值。
价格:10 金币
简单做法:
button.mouse_entered.connect(func():
description_label.text = shop_item.item.description
)
不过先不做也行。
34. 购买数量选择
第一版是:
点击一次买一个
以后可以做:
数量选择弹窗
买 1 / 买 5 / 买 10 / 最大
这需要:
QuantityDialog
SpinBox
确认按钮
暂时不做。
RPG 早期原型点击一次买一个完全够测。
35. 商店打开时隐藏交互提示
如果 ShopPanel 打开了,InteractLabel 还显示“按 E 交易”,会有点怪。
可以在 UI.gd 里管理,也可以 ShopPanel 打开时发信号。
ShopPanel.gd:
signal shop_opened
signal shop_closed
打开:
shop_opened.emit()
关闭:
shop_closed.emit()
UI.gd:
shop_panel.shop_opened.connect(func():
interact_label.visible = false
)
第一版也可以先不处理。
但建议加上,体验更干净。
36. ShopPanel 添加信号
signal shop_opened
signal shop_closed
open_shop:
func open_shop(shop_data: ShopData, player: Node):
current_shop = shop_data
current_player = player
mode = ShopMode.BUY
if current_player != null and current_player.has_method("lock_control_for_menu"):
current_player.lock_control_for_menu()
visible = true
refresh()
shop_opened.emit()
close_shop:
func close_shop():
if current_player != null and current_player.has_method("unlock_control_from_menu"):
current_player.unlock_control_from_menu()
visible = false
current_shop = null
current_player = null
shop_closed.emit()
37. ShopNPC 和普通 NPC 的关系
ShopNPC 可以是独立脚本。
但以后你可能想要:
商人先说一句话
然后打开商店
比如:
欢迎光临,要买点什么?
这时可以有两种做法:
1. ShopNPC 先打开 DialogueBox,再在对话结束后打开 ShopPanel
2. 做对话选项:购买 / 离开
第一种比较简单。
第二种更像正式 RPG。
但现在先不做,避免把商店和对话选项混在一起。
38. 商店和任务的关系
任务可能奖励商店折扣。
也可能任务完成后解锁新商品。
比如:
完成“收集史莱姆凝胶”
↓
商人开始售卖中型治疗药水
第一版不做,但你可以预留思路:
ShopItemData 加 unlock_quest_id
ShopPanel 显示商品前检查 QuestManager 是否完成该任务
以后你有任务系统了,这个很好扩展。
39. 商店是否应该是 Autoload
不建议。
商店通常不是全局唯一系统。
它更像:
某个 NPC 或地点提供的一组商品数据
所以 ShopPanel 是 UI。
ShopData 是资源。
ShopNPC 是场景里的交互对象。
不用做成 Autoload。
真正可能做成 Autoload 的是:
ItemDatabase
QuestManager
SaveManager
AudioManager
SceneLoader
Shop 不急。
40. 当前推荐节点结构
现在 UI 会更完整:
Main Node
├── QuestManager Node
├── World Node2D
│ ├── Ground TileMapLayer
│ ├── Obstacles TileMapLayer
│ ├── Interactables Node2D
│ │ ├── QuestNPC Area2D
│ │ └── ShopNPC Area2D
│ ├── Enemies Node2D
│ ├── Drops Node2D
│ ├── Player CharacterBody2D
│ │ └── Inventory Node
│ └── Foreground TileMapLayer
└── UI CanvasLayer
├── PlayerHUD Control
├── InventoryPanel Control
├── QuestPanel Control
├── ShopPanel Control
│ └── Panel
│ ├── TitleLabel Label
│ ├── GoldLabel Label
│ ├── Tabs HBoxContainer
│ │ ├── BuyTabButton Button
│ │ └── SellTabButton Button
│ ├── ItemList VBoxContainer
│ └── CloseButton Button
├── QuestToast Label
├── InteractLabel Label
├── DialogueBox Control
└── DeathPanel Control
数据资源:
res://data/items/small_potion.tres
res://data/items/slime_gel.tres
res://data/shops/shop_items/small_potion_shop_item.tres
res://data/shops/potion_shop.tres
41. 常见错误 1:按 E 没打开商店
排查:
ShopNPC 是否加入 interactable 组
Player 当前交互对象是否是 ShopNPC
ShopNPC 是否挂了 shop_npc.gd
shop_data 是否拖了
shop_panel 是否拖了
shop_panel 是否有 open_shop 方法
临时打印:
func interact(player: Node):
print("打开商店")
如果不打印,是交互系统没触发。
如果打印但 UI 不出现,是 shop_panel 引用或 open_shop 问题。
42. 常见错误 2:商品列表为空
排查:
ShopData.items 是否配置了
ShopItemData.item 是否配置了
ShopPanel.refresh 是否被调用
ItemList 路径是否正确
临时打印:
print("商品数量:", current_shop.items.size())
43. 常见错误 3:按钮一直是 disabled
排查:
玩家 gold 是否足够
ShopItemData.price 是否太高
current_player 是否正确
can_buy 是否返回 false
有限库存 stock 是否为 0
临时打印:
print("玩家金币:", current_player.gold, " 商品价格:", shop_item.price)
44. 常见错误 4:购买扣了金币但没获得物品
排查:
Player.pick_up_item 是否存在
Player.inventory 是否存在
ItemData 是否有效
Inventory.add_item 是否成功
背包 UI 是否刷新
代码里已经做了失败退钱:
if not added:
current_player.add_gold(shop_item.price)
这是一个安全保护。
45. 常见错误 5:购买后 GoldLabel 没刷新
有两个 GoldLabel:
PlayerHUD 里的 GoldLabel
ShopPanel 里的 GoldLabel
PlayerHUD 靠 gold_changed 更新。
ShopPanel 购买后靠:
refresh()
刷新。
如果 ShopPanel 的金币不变,检查 buy_item() 最后有没有调用 refresh()。
46. 常见错误 6:关闭商店后玩家不能动
检查:
close_shop 是否调用 unlock_control_from_menu
Player 是否有 unlock_control_from_menu 方法
PlayerState 是否允许 MENU -> IDLE
Esc 关闭和按钮关闭是否都走 close_shop
不要在不同地方写两套关闭逻辑。
统一走:
close_shop()
47. 常见错误 7:出售任务物品
解决:
任务物品 sell_price = 0
sell_price <= 0 时按钮 disabled
以后可以再加 sellable 字段。
48. 常见错误 8:多个商店库存一起减少
原因:
多个商店共用同一个 ShopItemData Resource
解决:
每个商店各自使用自己的 ShopItemData
或者运行时复制 ShopItemData
第一版建议每个商店单独配资源。
49. 这一部分最重要的记忆点
ShopItemData 表示一个商品
ShopData 表示一个商店
ShopNPC 负责交互时打开商店
ShopPanel 负责显示商品和处理购买
Player 负责金币和背包
购买流程是:检查金币 → 扣金币 → 加物品 → 刷新 UI
出售流程是:检查物品可卖 → 移除物品 → 增加金币 → 刷新 UI
打开商店时应锁住玩家控制
关闭商店时恢复玩家控制
第一版建议无限库存,有限库存后面再完善
50. 你现在的小练习
建议按这个顺序做:
1. 创建 ShopItemData.gd
2. 创建 ShopData.gd
3. 创建 small_potion_shop_item.tres
4. 创建 potion_shop.tres
5. UI 下创建 ShopPanel
6. ShopPanel 里添加 TitleLabel、GoldLabel、ItemList、CloseButton
7. 给 ShopPanel 挂 shop_panel.gd
8. 创建 ShopNPC
9. ShopNPC 加入 interactable 组
10. ShopNPC 设置 shop_data = potion_shop.tres
11. ShopNPC 设置 shop_panel
12. Player 添加 spend_gold()
13. 玩家靠近 ShopNPC 按 E 打开商店
14. 商店显示小型治疗药水 - 10 金币
15. 金币不足时按钮禁用
16. 给玩家加金币测试
17. 点击购买,扣 10 金币
18. 小型治疗药水进入背包
19. ShopPanel 金币刷新
20. InventoryPanel 显示药水数量增加
21. 添加 sell_price
22. ShopPanel 加 Buy / Sell 两个页签
23. 出售 slime_gel 后金币增加
完成这部分之后,你的 RPG 就有了“任务奖励金币 → 去商店买药水 → 战斗中使用药水”的经济闭环。
如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录