第十七部分:经验值与等级系统:击杀怪物获得经验、升级、属性成长、等级 UI

2026-05-19
370413 分钟
...

这一部分开始做 RPG 里非常核心的成长系统。

前面我们已经有了:

玩家能攻击
怪物能死亡
怪物能掉落物品
玩家能获得装备
玩家属性会变强

现在要继续加:

怪物死亡给经验
玩家获得经验
经验满了升级
升级提高属性
UI 显示等级和经验条

这样玩家打怪就不只是为了掉落物,也会有长期成长反馈。

1. 经验值系统要解决什么

经验系统的核心是:

玩家击败敌人

获得经验值

经验值达到升级需求

等级提升

基础属性成长

玩家更强

比如:

玩家等级 1
当前经验 0 / 100

击杀史莱姆
获得 20 经验

当前经验 20 / 100

再击杀几只
经验达到 100
升级到等级 2

攻击力 +2
最大生命 +10
防御 +1

这就是 RPG 最基础的成长闭环。

2. 第一版经验系统先做什么

第一阶段先做:

1. Player level
2. Player current_exp
3. Player exp_to_next_level
4. 怪物有 exp_reward
5. 怪物死亡时给玩家经验
6. 玩家经验满了升级
7. 升级后提高基础属性
8. UI 显示等级和经验条

先不要做:

职业系统
技能点
天赋树
属性点自由分配
等级上限突破
怪物等级压制
经验衰减
组队经验分配

这些都后面再说。

第一版目标非常简单:

打怪涨经验经验满了升级升级变强

3. Player 需要哪些经验字段

Player.gd 里加:

signal experience_changed(current_exp: int, exp_to_next_level: int)
signal level_changed(level: int)

@export var level: int = 1
@export var current_exp: int = 0
@export var exp_to_next_level: int = 100

含义:

level
当前等级

current_exp
当前等级已经积累的经验

exp_to_next_level
升到下一级需要多少经验

比如:

level = 1
current_exp = 40
exp_to_next_level = 100

表示:

等级 1
经验 40 / 100

4. 为什么要发 signal

经验变化后 UI 要刷新。

升级后 UI 也要刷新。

所以 Player 需要发信号:

experience_changed.emit(current_exp, exp_to_next_level)
level_changed.emit(level)

不要让 UI 每帧去查 Player 经验。

经验变化时主动通知,结构更干净。

这和前面的血量系统一样:

血量变化health_changed
金币变化gold_changed
装备变化equipment_changed
经验变化experience_changed
等级变化level_changed

你会发现整个 Godot 项目里,signal 是非常常用的“状态变化通知机制”。

5. add_experience()

玩家获得经验时调用:

func add_experience(amount: int):
    if amount <= 0:
        return

    current_exp += amount
    print("获得经验:", amount)

    while current_exp >= exp_to_next_level:
        current_exp -= exp_to_next_level
        level_up()

    experience_changed.emit(current_exp, exp_to_next_level)

这里用了 while,不是 if

为什么?

因为玩家可能一次获得大量经验,连续升好几级。

比如:

当前经验 90 / 100
获得经验 250

如果只用 if,最多升一级。

但实际应该:

90 + 250 = 340
升到 2 240
如果下一级需要 120再升到 3 120
如果再满足条件继续升

所以用:

while current_exp >= exp_to_next_level:

更稳。

6. level_up()

升级时要做:

等级 +1
提升基础属性
重新计算最终属性
恢复部分生命或回满生命
计算下一级经验需求
发出等级变化信号

基础版:

func level_up():
    level += 1

    base_attack += 2
    base_defense += 1
    base_max_hp += 10

    exp_to_next_level = calculate_exp_to_next_level(level)

    recalculate_stats()
    current_hp = max_hp

    level_changed.emit(level)
    health_changed.emit(current_hp, max_hp)

    print("升级!当前等级:", level)

这里选择了升级后生命回满。

这在 RPG 里很常见,反馈比较爽。

如果你不想回满,可以改成:

current_hp += 10
current_hp = min(current_hp, max_hp)

但第一版建议回满,简单直接。

7. calculate_exp_to_next_level()

升级所需经验可以先用简单公式:

func calculate_exp_to_next_level(current_level: int) -> int:
    return 100 + (current_level - 1) * 50

效果:

12100 经验
23150 经验
34200 经验
45250 经验

这个是线性成长。

第一版很适合测试。

后面可以改成曲线:

func calculate_exp_to_next_level(current_level: int) -> int:
    return int(100 * pow(1.25, current_level - 1))

效果:

等级越高需要经验增长越快

但第一版建议先用线性公式。

数值更容易看懂。

8. 初始化经验 UI

Player _ready() 里要发一次信号,让 UI 初始化。

如果你的 Player.gd 里已经有 _ready(),把这些内容合并进去:

func _ready():
    recalculate_stats()
    current_hp = max_hp

    health_changed.emit(current_hp, max_hp)
    experience_changed.emit(current_exp, exp_to_next_level)
    level_changed.emit(level)

注意:如果 UI 还没连接这些信号,可能收不到第一次 emit。

所以 UI 的 setup(player) 里也应该主动读取一次当前值。

和血条一样。

9. Player.gd 经验相关完整片段

signal experience_changed(current_exp: int, exp_to_next_level: int)
signal level_changed(level: int)

@export var level: int = 1
@export var current_exp: int = 0
@export var exp_to_next_level: int = 100

func add_experience(amount: int):
    if amount <= 0:
        return

    current_exp += amount
    print("获得经验:", amount)

    while current_exp >= exp_to_next_level:
        current_exp -= exp_to_next_level
        level_up()

    experience_changed.emit(current_exp, exp_to_next_level)

func level_up():
    level += 1

    base_attack += 2
    base_defense += 1
    base_max_hp += 10

    exp_to_next_level = calculate_exp_to_next_level(level)

    recalculate_stats()

    current_hp = max_hp

    level_changed.emit(level)
    health_changed.emit(current_hp, max_hp)

    print("升级!当前等级:", level)

func calculate_exp_to_next_level(current_level: int) -> int:
    return 100 + (current_level - 1) * 50

10. 升级时属性怎么成长

第一版可以写死:

每升一级
攻击 +2
防御 +1
最大生命 +10

代码:

base_attack += 2
base_defense += 1
base_max_hp += 10

这很直观。

但以后你可能需要更灵活:

战士
攻击 +3
防御 +2
生命 +15

法师
攻击 +1
魔法 +4
生命 +8

游侠
攻击 +2
速度 +2
生命 +10

那时可以做:

LevelGrowthData Resource

现在先别做。

第一版先写死,跑通成长闭环。

11. 怪物要有 exp_reward

Enemy.gd 加:

@export var exp_reward: int = 20

不同怪物给不同经验:

史莱姆20
野狼35
哥布林50
精英怪120
Boss500

在 Inspector 里调就行。

12. 怪物死亡时怎么给经验

怪物死亡时需要知道是谁击杀了它。

最简单的方式是:

Player 攻击 Hurtbox
Hurtbox 调用 Enemy.take_damage(amount, attacker)
Enemy 记录最后攻击者
死亡时给最后攻击者经验

现在我们要稍微改一下伤害传递。

13. Player 攻击时传 self

之前 Player 命中怪物可能是:

area.take_damage(get_attack_damage())

改成:

area.take_damage(get_attack_damage(), self)

完整:

func _on_attack_area_entered(area):
    if current_state != PlayerState.ATTACK:
        return

    if not area.is_in_group("hurtbox"):
        return

    if hit_targets.has(area):
        return

    hit_targets.append(area)

    if area.has_method("take_damage"):
        area.take_damage(get_attack_damage(), self)

这样怪物知道攻击者是谁。

14. Hurtbox.gd 支持 attacker

之前 Hurtbox.gd 可能是:

func take_damage(amount: int):
    var owner_node = get_parent()

    if owner_node != null and owner_node.has_method("take_damage"):
        owner_node.take_damage(amount)

改成:

func take_damage(amount: int, attacker: Node = null):
    var owner_node = get_parent()

    if owner_node != null and owner_node.has_method("take_damage"):
        owner_node.take_damage(amount, attacker)

如果你的 Hurtbox 不是直接挂在 Enemy 下面,用 @export var owner_node 版本也一样:

@export var owner_node: Node

func take_damage(amount: int, attacker: Node = null):
    if owner_node != null and owner_node.has_method("take_damage"):
        owner_node.take_damage(amount, attacker)

15. Enemy.gd 记录 last_attacker

Enemy.gd 加:

var last_attacker: Node = null

take_damage 改成:

func take_damage(amount: int, attacker: Node = null):
    if current_state == EnemyState.DEAD:
        return

    last_attacker = attacker

    current_hp -= amount
    current_hp = clamp(current_hp, 0, max_hp)

    health_changed.emit(current_hp, max_hp)

    print(name, " 受伤:", amount, " 当前血量:", current_hp)

    if current_hp <= 0:
        change_state(EnemyState.DEAD)
        died.emit()
        return

    change_state(EnemyState.HURT)

死亡时给经验。

16. Enemy 死亡时发经验

在死亡逻辑里加:

func give_experience_to_attacker():
    if last_attacker == null:
        return

    if last_attacker.has_method("add_experience"):
        last_attacker.add_experience(exp_reward)

然后在 DEAD 状态进入时调用:

func enter_state(state: EnemyState):
    match state:
        EnemyState.DEAD:
            give_experience_to_attacker()
            drop_loot()
            queue_free()

如果你要播放死亡动画,可以这样:

func enter_state(state: EnemyState):
    match state:
        EnemyState.DEAD:
            give_experience_to_attacker()
            drop_loot()
            velocity = Vector2.ZERO
            attack_area.monitoring = false
            detection_area.monitoring = false
            hurtbox.monitorable = false
            play_animation("dead")

动画结束后再:

queue_free()

注意:经验和掉落要避免重复发。

只应该在死亡进入时发一次。

17. 防止经验重复发

如果 Enemy 的 enter_state(DEAD) 可能被调用多次,就可能重复给经验。

理论上 change_state() 已经防止同状态重复进入。

但为了更稳,可以加:

var reward_given: bool = false
func give_death_rewards():
    if reward_given:
        return

    reward_given = true

    give_experience_to_attacker()
    drop_loot()

DEAD 状态:

func enter_state(state: EnemyState):
    match state:
        EnemyState.DEAD:
            give_death_rewards()
            ...

这样不会重复发奖励。

18. Enemy 死亡奖励完整片段

@export var exp_reward: int = 20

var last_attacker: Node = null
var reward_given: bool = false

func take_damage(amount: int, attacker: Node = null):
    if current_state == EnemyState.DEAD:
        return

    last_attacker = attacker

    current_hp -= amount
    current_hp = clamp(current_hp, 0, max_hp)

    health_changed.emit(current_hp, max_hp)

    if current_hp <= 0:
        change_state(EnemyState.DEAD)
        died.emit()
        return

    change_state(EnemyState.HURT)

func give_death_rewards():
    if reward_given:
        return

    reward_given = true

    give_experience_to_attacker()
    drop_loot()

func give_experience_to_attacker():
    if last_attacker == null:
        return

    if last_attacker.has_method("add_experience"):
        last_attacker.add_experience(exp_reward)

19. PlayerHUD 显示等级和经验条

现在 PlayerHUD 里已有:

HPBar
HPLabel
GoldLabel

加:

LevelLabel
ExpBar
ExpLabel

结构:

PlayerHUD Control
├── HPBar ProgressBar
├── HPLabel Label
├── GoldLabel Label
├── LevelLabel Label
├── ExpBar ProgressBar
└── ExpLabel Label

显示效果:

Lv. 3
HP: 90 / 120
EXP: 40 / 200
金币80

20. PlayerHUD.gd 添加经验显示

@onready var level_label: Label = $LevelLabel
@onready var exp_bar: ProgressBar = $ExpBar
@onready var exp_label: Label = $ExpLabel

setup 里连接:

func setup(player: Node):
    if player == null:
        return

    if player.has_signal("health_changed"):
        player.health_changed.connect(_on_player_health_changed)

    if player.has_signal("gold_changed"):
        player.gold_changed.connect(_on_gold_changed)

    if player.has_signal("experience_changed"):
        player.experience_changed.connect(_on_experience_changed)

    if player.has_signal("level_changed"):
        player.level_changed.connect(_on_level_changed)

    if "current_hp" in player and "max_hp" in player:
        _on_player_health_changed(player.current_hp, player.max_hp)

    if "gold" in player:
        _on_gold_changed(player.gold)

    if "current_exp" in player and "exp_to_next_level" in player:
        _on_experience_changed(player.current_exp, player.exp_to_next_level)

    if "level" in player:
        _on_level_changed(player.level)

21. PlayerHUD 经验相关方法

func _on_experience_changed(current_exp: int, exp_to_next_level: int):
    exp_bar.min_value = 0
    exp_bar.max_value = exp_to_next_level
    exp_bar.value = current_exp
    exp_label.text = "EXP: %d / %d" % [current_exp, exp_to_next_level]

func _on_level_changed(level: int):
    level_label.text = "Lv. %d" % level

现在玩家获得经验后,经验条会涨。

升级后等级显示会变。

22. 升级时经验条为什么可能刷新两次

add_experience() 里:

while current_exp >= exp_to_next_level:
    current_exp -= exp_to_next_level
    level_up()

experience_changed.emit(current_exp, exp_to_next_level)

level_up() 里可能也 emit 了 health 和 level。

最终经验条在 add_experience() 末尾刷新。

这没问题。

如果你在 level_up() 里也 emit experience_changed,也能跑,但可能刷新多次。

建议经验刷新统一放在 add_experience() 最后。

23. 升级提示 UI

升级最好有提示。

UI 下加:

LevelUpToast Label

默认隐藏。

UI.gd 或 PlayerHUD.gd 都可以处理。

PlayerHUD.gd:

@onready var level_up_toast: Label = $LevelUpToast

如果 LevelUpToast 不在 PlayerHUD 下,而是在 UI 下,那就放 UI.gd 里处理。

这里先假设放 PlayerHUD 下。

func _on_level_changed(level: int):
    level_label.text = "Lv. %d" % level
    show_level_up_toast(level)

func show_level_up_toast(level: int):
    level_up_toast.text = "升级!Lv. %d" % level
    level_up_toast.visible = true

    await get_tree().create_timer(2.0).timeout

    level_up_toast.visible = false

注意:初始化时调用 _on_level_changed(level) 也会弹升级提示。

这不太好。

所以要区分初始化和真正升级。

24. 避免初始化时弹升级提示

可以加变量:

var initialized: bool = false

setup 最后:

initialized = true

然后:

func _on_level_changed(level: int):
    level_label.text = "Lv. %d" % level

    if initialized:
        show_level_up_toast(level)

完整思路:

var initialized: bool = false

func setup(player: Node):
    ...
    if "level" in player:
        _on_level_changed(player.level)

    initialized = true

这样第一次设置等级不会弹。

真正升级时会弹。

25. 升级时是否要播放音效和特效

第一版可以先用 Label 提示。

以后可以加:

升级音效
金光粒子
玩家闪光
属性增加提示
屏幕短暂停顿

现在先不做。

升级提示能显示就足够。

26. 等级上限

可以加:

@export var max_level: int = 99

add_experience 里:

func add_experience(amount: int):
    if amount <= 0:
        return

    if level >= max_level:
        return

    current_exp += amount

    while current_exp >= exp_to_next_level and level < max_level:
        current_exp -= exp_to_next_level
        level_up()

    if level >= max_level:
        current_exp = 0

    experience_changed.emit(current_exp, exp_to_next_level)

level_up 里:

func level_up():
    if level >= max_level:
        return

    level += 1
    ...

第一版可以先不设置上限。

但如果你担心无限升级,就加上。

27. 等级和装备属性如何叠加

当前属性来源有两类:

等级成长提高 base_attack / base_defense / base_max_hp
装备加成提高 final_attack / final_defense / max_hp

比如:

等级 1
base_attack = 10

装备木剑
attack_bonus = 5

final_attack = 15

升级到 2
base_attack = 12
木剑仍然 +5

final_attack = 17

这个设计非常合理。

等级改变基础属性。

装备在基础属性上加成。

所以升级后一定要调用:

recalculate_stats()

否则装备和等级属性不会重新计算。

28. 经验是否应该给击杀者还是玩家固定获得

现在我们用:

last_attacker.add_experience(exp_reward)

这适合单人游戏。

如果以后有:

宠物
召唤物
陷阱
NPC 队友

那经验归属会更复杂。

比如:

召唤物杀死怪物经验给玩家
NPC 杀死怪物玩家不给经验
陷阱杀死怪物谁放的陷阱获得经验

这些都后面再处理。

第一版:

谁最后打死谁获得经验

够用。

29. 击杀任务和经验可以共用死亡事件

怪物死亡时现在会:

给经验
掉落
queue_free

以后还要:

通知任务系统击杀了某种怪物

这时可以给 Enemy 加:

signal enemy_died(enemy_id: String, killer: Node)

死亡时:

enemy_died.emit(enemy_id, last_attacker)

QuestManager 监听后更新击杀任务。

现在先不写。

但你要知道,怪物死亡是一个很重要的事件:

经验
掉落
任务
成就
图鉴
音效
特效

后面都会接到这里。

30. 经验数值怎么定

第一版可以这样:

升级经验
12100
23150
34200

怪物经验
史莱姆20
野狼35
哥布林50

大致上:

等级 1 5 只史莱姆升级
等级 2 需要更多

不要一开始把数值搞太复杂。

先让你自己玩起来感觉:

打几只怪能升一级
升级后明显变强
但不是强到秒杀所有怪

后面再慢慢调。

数值系统不是一锤子买卖,它是锅汤,边煮边尝。

31. 怪物等级

以后怪物也可以有 level。

Enemy.gd:

@export var level: int = 1

然后根据 level 计算:

max_hp
attack_damage
exp_reward

比如:

func setup_by_level():
    max_hp = 20 + level * 10
    attack_damage = 5 + level * 2
    exp_reward = 15 + level * 5

第一版不用。

先手动给每种怪配置数值,更直观。

32. PlayerHUD.gd 经验完整片段

@onready var level_label: Label = $LevelLabel
@onready var exp_bar: ProgressBar = $ExpBar
@onready var exp_label: Label = $ExpLabel
@onready var level_up_toast: Label = $LevelUpToast

var initialized: bool = false

func setup(player: Node):
    if player == null:
        return

    if player.has_signal("health_changed"):
        player.health_changed.connect(_on_player_health_changed)

    if player.has_signal("gold_changed"):
        player.gold_changed.connect(_on_gold_changed)

    if player.has_signal("experience_changed"):
        player.experience_changed.connect(_on_experience_changed)

    if player.has_signal("level_changed"):
        player.level_changed.connect(_on_level_changed)

    if "current_hp" in player and "max_hp" in player:
        _on_player_health_changed(player.current_hp, player.max_hp)

    if "gold" in player:
        _on_gold_changed(player.gold)

    if "current_exp" in player and "exp_to_next_level" in player:
        _on_experience_changed(player.current_exp, player.exp_to_next_level)

    if "level" in player:
        _on_level_changed(player.level)

    initialized = true

func _on_experience_changed(current_exp: int, exp_to_next_level: int):
    exp_bar.min_value = 0
    exp_bar.max_value = exp_to_next_level
    exp_bar.value = current_exp
    exp_label.text = "EXP: %d / %d" % [current_exp, exp_to_next_level]

func _on_level_changed(level: int):
    level_label.text = "Lv. %d" % level

    if initialized:
        show_level_up_toast(level)

func show_level_up_toast(level: int):
    level_up_toast.text = "升级!Lv. %d" % level
    level_up_toast.visible = true

    await get_tree().create_timer(2.0).timeout

    level_up_toast.visible = false

如果你不想做升级提示,LevelUpToast 可以先不加。

33. Player.gd 经验 + 属性完整整合片段

signal health_changed(current_hp: int, max_hp: int)
signal experience_changed(current_exp: int, exp_to_next_level: int)
signal level_changed(level: int)
signal stats_changed

@export var level: int = 1
@export var current_exp: int = 0
@export var exp_to_next_level: int = 100

@export var base_attack: int = 10
@export var base_defense: int = 0
@export var base_max_hp: int = 100

var final_attack: int = 10
var final_defense: int = 0
var max_hp: int = 100
var current_hp: int = 100

func initialize_player_stats():
    recalculate_stats()
    current_hp = max_hp

    health_changed.emit(current_hp, max_hp)
    experience_changed.emit(current_exp, exp_to_next_level)
    level_changed.emit(level)

func add_experience(amount: int):
    if amount <= 0:
        return

    current_exp += amount
    print("获得经验:", amount)

    while current_exp >= exp_to_next_level:
        current_exp -= exp_to_next_level
        level_up()

    experience_changed.emit(current_exp, exp_to_next_level)

func level_up():
    level += 1

    base_attack += 2
    base_defense += 1
    base_max_hp += 10

    exp_to_next_level = calculate_exp_to_next_level(level)

    recalculate_stats()

    current_hp = max_hp

    level_changed.emit(level)
    health_changed.emit(current_hp, max_hp)

    print("升级!当前等级:", level)

func calculate_exp_to_next_level(current_level: int) -> int:
    return 100 + (current_level - 1) * 50

func recalculate_stats():
    final_attack = base_attack
    final_defense = base_defense
    max_hp = base_max_hp

    for item in equipped_items.values():
        if item == null:
            continue

        final_attack += item.attack_bonus
        final_defense += item.defense_bonus
        max_hp += item.max_hp_bonus

    current_hp = min(current_hp, max_hp)

    stats_changed.emit()
    health_changed.emit(current_hp, max_hp)

这里我把初始化写成了:

initialize_player_stats()

你可以在 _ready() 里调用它。

这样避免 _ready() 过长。

34. Enemy.gd 经验奖励完整片段

signal died

@export var exp_reward: int = 20

var last_attacker: Node = null
var reward_given: bool = false

func take_damage(amount: int, attacker: Node = null):
    if current_state == EnemyState.DEAD:
        return

    last_attacker = attacker

    current_hp -= amount
    current_hp = clamp(current_hp, 0, max_hp)

    health_changed.emit(current_hp, max_hp)

    print(name, " 受伤:", amount, " 当前血量:", current_hp)

    if current_hp <= 0:
        change_state(EnemyState.DEAD)
        died.emit()
        return

    change_state(EnemyState.HURT)

func give_death_rewards():
    if reward_given:
        return

    reward_given = true

    give_experience_to_attacker()
    drop_loot()

func give_experience_to_attacker():
    if last_attacker == null:
        return

    if last_attacker.has_method("add_experience"):
        last_attacker.add_experience(exp_reward)

DEAD 状态中:

func enter_state(state: EnemyState):
    match state:
        EnemyState.DEAD:
            give_death_rewards()

            velocity = Vector2.ZERO
            attack_area.monitoring = false
            detection_area.monitoring = false
            hurtbox.monitorable = false

            play_animation("dead")

如果没有死亡动画:

func enter_state(state: EnemyState):
    match state:
        EnemyState.DEAD:
            give_death_rewards()
            queue_free()

35. 测试经验系统的最小步骤

你可以先不接 UI,只测试 Output。

做:

1. Player add_experience()
2. Enemy exp_reward = 20
3. Player 攻击时传 self
4. Hurtbox.take_damage(amount, attacker)
5. Enemy.take_damage(amount, attacker)
6. Enemy 死亡时 last_attacker.add_experience(exp_reward)

打死史莱姆后应该输出:

获得经验20

如果没有,先别管 UI。

先把数据闭环跑通。

36. 常见错误 1:怪物死亡不给经验

排查:

Player 攻击时有没有传 self
Hurtbox.take_damage 是否接收 attacker
Enemy.take_damage 是否保存 last_attacker
Enemy 死亡时是否调用 give_death_rewards()
last_attacker 是否有 add_experience 方法
Enemy.exp_reward 是否大于 0

临时打印:

print("last_attacker: ", last_attacker)

如果是 null,说明 attacker 没一路传过来。

37. 常见错误 2:经验增加了但 UI 不动

排查:

Player 是否 emit experience_changed
PlayerHUD 是否连接 experience_changed
PlayerHUD 是否有 ExpBar / ExpLabel
setup(player) 是否调用
节点路径是否正确

临时打印:

func _on_experience_changed(current_exp: int, exp_to_next_level: int):
    print("经验 UI 更新:", current_exp, "/", exp_to_next_level)

如果打印了但 UI 不动,是 ProgressBar 路径或显示问题。

如果没打印,是信号没连上。

38. 常见错误 3:升级后经验条异常

排查:

current_exp 是否减去了旧的 exp_to_next_level
level_up 是否更新了新的 exp_to_next_level
add_experience 最后是否 emit experience_changed
while 循环是否正确

正确顺序:

经验增加
如果够升级
先减掉当前升级所需经验
升级
计算下一等级所需经验
最后刷新经验条

39. 常见错误 4:升级后血条最大值没变

排查:

level_up 是否增加 base_max_hp
level_up 是否调用 recalculate_stats
recalculate_stats 是否更新 max_hp
recalculate_stats 是否 emit health_changed
PlayerHUD 是否监听 health_changed

血条最大值不变,通常是 health_changed 没发。

40. 常见错误 5:升级后装备加成丢了

原因通常是你在升级时直接改了 final 属性。

比如错误写法:

final_attack += 2

然后 recalculate_stats 又覆盖掉。

正确做法:

base_attack += 2
recalculate_stats()

记住:

等级成长改 base 属性
装备加成算进 final 属性

41. 常见错误 6:一次获得大量经验只升一级

说明你用了:

if current_exp >= exp_to_next_level:

应该用:

while current_exp >= exp_to_next_level:

这样才能连续升级。

42. 常见错误 7:初始化时弹“升级”

这是 UI 初始化和真实升级没分开。

解决:

var initialized = false

setup 时先刷新 UI,再:

initialized = true

_on_level_changed() 里:

if initialized:
    show_level_up_toast(level)

43. 经验系统和存档

以后存档要保存:

{
  "level": 3,
  "current_exp": 40,
  "base_attack": 14,
  "base_defense": 2,
  "base_max_hp": 120
}

装备和背包也要保存。

读档后要:

恢复等级和基础属性
恢复装备
调用 recalculate_stats()
刷新 UI

现在先不做存档。

但你要知道,等级系统的数据以后一定要存。

44. 等级系统和任务奖励

任务也可以奖励经验。

QuestData 可以加:

@export var reward_exp: int = 0

QuestManager 的 give_rewards:

if quest.reward_exp > 0 and player.has_method("add_experience"):
    player.add_experience(quest.reward_exp)

这样任务完成后除了金币和物品,还能给经验。

第一版可以加上,很实用。

45. QuestData 加 reward_exp

QuestData.gd:

@export var reward_exp: int = 0

QuestManager.gd:

func give_rewards(quest: QuestData, player: Node):
    if quest.reward_gold > 0 and player.has_method("add_gold"):
        player.add_gold(quest.reward_gold)

    if quest.reward_exp > 0 and player.has_method("add_experience"):
        player.add_experience(quest.reward_exp)

    var reward_count = quest.reward_items.size()

    for i in range(reward_count):
        var item = quest.reward_items[i]

        if item == null:
            continue

        var quantity = 1

        if i < quest.reward_item_quantities.size():
            quantity = quest.reward_item_quantities[i]

        if player.has_method("pick_up_item"):
            player.pick_up_item(item, quantity)

这样任务系统也接入成长系统了。

46. 任务奖励经验的好处

如果玩家只靠打怪升级,可能会变成刷怪游戏。

任务奖励经验可以鼓励玩家:

探索
对话
收集
完成目标
推进剧情

所以 RPG 里通常:

打怪给少量经验
任务给大量经验
Boss 给大量经验

你可以设计:

史莱姆20 经验
收集史莱姆凝胶任务100 经验

这样玩家会愿意完成任务。

47. 当前推荐节点结构

现在 UI 会变成:

UI CanvasLayer
├── PlayerHUD Control
│   ├── HPBar ProgressBar
│   ├── HPLabel Label
│   ├── GoldLabel Label
│   ├── LevelLabel Label
│   ├── ExpBar ProgressBar
│   ├── ExpLabel Label
│   └── LevelUpToast Label
├── InventoryPanel Control
├── EquipmentPanel Control
├── QuestPanel Control
├── ShopPanel Control
├── InteractLabel Label
├── DialogueBox Control
└── DeathPanel Control

Player 现在拥有:

移动
状态机
血量
无敌
攻击
背包
金币
装备
经验
等级

这时 Player.gd 已经会比较长。

后面我们会逐步讲如何拆成组件,比如:

HealthComponent
InventoryComponent
EquipmentComponent
ExperienceComponent
CombatComponent

但现在先让功能跑通。

不要一边搭楼一边重构地基,容易把自己埋进去。

48. 这一部分最重要的记忆点

经验系统核心是 levelcurrent_expexp_to_next_level
怪物需要 exp_reward
玩家获得经验用 add_experience()
升级用 level_up()
经验够了要用 while 连续升级
等级成长应该修改 base 属性
装备加成通过 recalculate_stats 算到 final 属性
怪物死亡时通过 last_attacker 给经验
PlayerHUD 监听 experience_changed level_changed
任务也可以奖励经验

49. 你现在的小练习

建议按这个顺序做:

1. Player 添加 level / current_exp / exp_to_next_level
2. Player 添加 experience_changed / level_changed 信号
3. Player add_experience()
4. Player level_up()
5. Player calculate_exp_to_next_level()
6. level_up base_attack +2
7. level_up base_defense +1
8. level_up base_max_hp +10
9. level_up recalculate_stats()
10. Enemy 添加 exp_reward
11. Player 攻击 Hurtbox 时传 self
12. Hurtbox attacker 传给 Enemy
13. Enemy.take_damage 记录 last_attacker
14. Enemy 死亡时给 last_attacker 经验
15. PlayerHUD 添加 LevelLabel / ExpBar / ExpLabel
16. PlayerHUD 监听 experience_changed
17. PlayerHUD 监听 level_changed
18. 打死史莱姆后经验条增加
19. 经验满后玩家升级
20. 升级后攻击力防御最大生命提高
21. QuestData 添加 reward_exp
22. 完成任务后也能获得经验

完成这部分之后,你的 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 官方文档也把节点和场景放在一起讲:多个节点组成树状结构后...
学习

评论

请登录后发表评论

去登录
加载评论中...