第十五部分:商店系统基础:ShopItem、购买/出售、金币扣除、库存、商人 NPC

2026-05-19
334912 分钟
...

这一部分开始做 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 有一个 SHOPMENU 状态。

如果你之前的 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 里添加 TitleLabelGoldLabelItemListCloseButton
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 就有了“任务奖励金币 → 去商店买药水 → 战斗中使用药水”的经济闭环。

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
godot2026-07-27
Godot 4 常用 UI 节点详解
Godot 4 常用 UI 节点详解 在 Godot 4 中,UI 系统基于 Control(控件) 节点构建。所有 UI 节点都继承自 Control,形成一棵完整的 UI 树。与游戏引擎中常见的"Canvas + DOM"模式不同,Godot 的 UI 系统是声明式的——你在场景中搭好节点树,引擎自动完成布局计算。 一、布局系统:Container 家族 Container 是 Godot UI 的 骨架 。它决定了子节点的大小和位...
学习
godot2026-07-09
Godot 4 自动地形系统(AutoTileSet / Terrains)完全指南
前言 在 2D 游戏开发中,地形瓦片(tile)的拼接是一个绕不开的问题。想象一下:你有一片草地、一条河流、一段平台——如果每一块边缘、角落、过渡区域都要手动选择对应的瓦片图,工作量将是巨大的。 自动地形系统 就是为了解决这个问题而生的。 一、什么是自动地形(Autotiling)? 自动地形的核心思想很简单: 你只管画,引擎帮你选对瓦片 。 当你在 TileMap 上绘制地形时,引擎会自动检测每个瓦片的上下左右邻居,然后根据预设的规则...
学习
godot2026-06-25
Godot 中 zindex 和 ysort 的区别总结
在 Godot 2D 游戏开发中,角色、树木、怪物、地面、技能特效、UI 都需要正确的显示顺序。比如角色走到树前面时,角色应该挡住树;角色走到树后面时,树又应该挡住角色。 这种显示顺序主要和两个概念有关: 和 。 其中 用来手动控制图层顺序, 用来根据物体的 Y 坐标自动排序。 一句话理解 是手动分层。 是根据 Y 坐标自动排序。 简单来说: | 属性 | 作用 | 适合场景 | | | | | | | 数值越大,显示越靠前 | 地面、...
学习
godot2026-06-11
Tileset 资源图的标准和规范
一、什么是 Tileset 资源图 Tileset,中文通常叫“图块资源图”或“瓦片图”,是 2D 游戏中非常常见的一种地图资源组织方式。 简单来说,Tileset 就是把很多小图块按照固定尺寸排列在一张图片里。游戏引擎会按照固定的格子大小去切割这张图片,然后把每个小格子当成一个独立的地图块使用。 比如一个 32×32 像素的 Tileset 中,每一个 tile 都是 32×32 像素。地图编辑器或游戏引擎会按照 32×32 的网格,...
学习
godot2026-05-29
用户角色精灵图制作角色的完整流程
在 2D RPG 游戏里,角色通常不是用一张单独图片完成的,而是用一张“角色精灵图”来做。 所谓角色精灵图,通常是一张包含多个动作帧的大图。比如角色向下走有 4 帧,向左走有 4 帧,向右走有 4 帧,向上走有 4 帧。Godot 会根据这些帧不断切换图片,看起来角色就动起来了。 这篇文章主要介绍:拿到一张角色精灵图之后,如何在 Godot 中把它做成一个可以正常移动、播放动画、和地图产生遮挡关系的角色。 一、先理解角色精灵图是什么 角...
学习
godot2026-05-26
Godot 节点系统详细介绍
Godot 里最核心的东西不是“类”,也不是“组件”,而是 节点 Node 。 你可以把 Godot 的节点理解成: 在前端里,一个页面是由很多 DOM 元素组成的; 在 Godot 里,一个游戏场景是由很多 Node 节点组成的。 比如一个玩家角色,可能不是一个单独对象,而是这样的结构: 这里的 是根节点,下面挂着显示图片、播放动画、碰撞检测、摄像机、音效等子节点。 Godot 官方文档也把节点和场景放在一起讲:多个节点组成树状结构后...
学习

评论

请登录后发表评论

去登录
加载评论中...