第二十六部分:Buff / Debuff 系统基础:中毒、燃烧、减速、眩晕、攻击提升、防御提升、持续时间管理

2026-05-19
388513 分钟
...

这一部分开始做 Buff / Debuff 系统。

前面我们已经有了:

玩家普通攻击
技能系统
血量
装备
等级
怪物受伤
技能冷却
蓝量 / 体力
AOE 技能
Projectile 技能

现在要继续升级战斗状态。

比如:

火球术命中敌人后让敌人燃烧 5
毒液攻击让玩家中毒每秒掉血
冰霜新星让敌人减速
眩晕技能让敌人不能移动和攻击
战吼让玩家攻击力提升 10
护盾技能让玩家防御提升 8

这就是 Buff / Debuff 系统。

1. Buff 和 Debuff 是什么

简单理解:

Buff
对目标有益的临时状态

Debuff
对目标有害的临时状态

比如 Buff:

攻击提升
防御提升
移动速度提升
回血
护盾
暴击率提升

比如 Debuff:

中毒
燃烧
减速
眩晕
冰冻
沉默
防御降低
持续流血

在代码层面,Buff 和 Debuff 可以用同一套系统处理。

区别只是:

有益效果Buff
负面效果Debuff

所以我们可以统一叫:

StatusEffect

或者:

BuffData

为了 RPG 里好理解,这一部分统一用:

Buff

但它可以表示正面 Buff,也可以表示负面 Debuff。

2. 第一版 Buff 系统要做什么

第一版建议做:

1. BuffData Resource 定义 Buff
2. ActiveBuff 记录运行中的 Buff 状态
3. BuffComponent 管理某个角色身上的 Buff
4. 支持持续时间
5. 支持每秒 Tick
6. 支持中毒持续掉血
7. 支持燃烧持续掉血
8. 支持减速
9. 支持眩晕
10. 支持攻击提升
11. 支持防御提升
12. 支持 Buff 到期自动移除
13. 支持 UI 显示当前 Buff

先不要做:

复杂叠层规则
驱散
免疫
抗性
Buff 来源归属
PVP 规则
状态优先级
复杂公式系统
技能联动
Buff 图标倒计时动画

第一版核心是:

给角色添加状态
状态持续一段时间
状态期间影响角色
时间到了自动消失

3. Buff 系统应该挂在哪里

玩家和怪物都可能中 Buff。

所以不要只写在 Player.gd 里。

推荐做一个组件:

BuffComponent

节点结构:

Player CharacterBody2D
├── BuffComponent Node
├── Inventory
├── AttackArea
└── Hurtbox

怪物:

Slime CharacterBody2D
├── BuffComponent Node
├── DetectionArea
├── AttackArea
└── Hurtbox

这样 Player 和 Enemy 都能复用同一套 Buff 系统。

4. BuffData 是 Buff 的定义

创建:

res://scripts/buffs/buff_data.gd
class_name BuffData
extends Resource

enum BuffType {
    POISON,
    BURN,
    SLOW,
    STUN,
    ATTACK_UP,
    DEFENSE_UP,
    HEAL_OVER_TIME,
    DAMAGE_OVER_TIME
}

enum BuffCategory {
    BUFF,
    DEBUFF
}

@export var id: String = ""
@export var display_name: String = ""
@export_multiline var description: String = ""
@export var icon: Texture2D

@export var buff_type: BuffType = BuffType.POISON
@export var category: BuffCategory = BuffCategory.DEBUFF

@export var duration: float = 5.0
@export var tick_interval: float = 1.0

@export var damage_per_tick: int = 0
@export var heal_per_tick: int = 0

@export var attack_bonus: int = 0
@export var defense_bonus: int = 0
@export var move_speed_multiplier: float = 1.0

@export var can_stack: bool = false
@export var max_stack: int = 1

5. 字段解释

id
Buff 唯一 ID比如 poisonburnslow

display_name
显示名称比如 中毒燃烧减速

description
描述文本

icon
UI 图标

buff_type
Buff 类型

category
正面还是负面

duration
持续时间单位秒

tick_interval
每隔多少秒触发一次 Tick

damage_per_tick
每次 Tick 造成多少伤害

heal_per_tick
每次 Tick 恢复多少生命

attack_bonus
攻击加成

defense_bonus
防御加成

move_speed_multiplier
移动速度倍率

can_stack
是否允许叠加

max_stack
最大叠加层数

比如中毒:

duration = 6
tick_interval = 1
damage_per_tick = 3

表示:

持续 6
 1 秒掉 3 点血

6. 创建中毒 Buff

创建资源:

res://data/buffs/poison.tres

类型:

BuffData

设置:

id = poison
display_name = 中毒
description = 每秒受到毒素伤害
buff_type = POISON
category = DEBUFF
duration = 6
tick_interval = 1
damage_per_tick = 3
move_speed_multiplier = 1.0
can_stack = false
max_stack = 1

7. 创建燃烧 Buff

创建:

res://data/buffs/burn.tres

设置:

id = burn
display_name = 燃烧
description = 持续受到火焰伤害
buff_type = BURN
category = DEBUFF
duration = 4
tick_interval = 1
damage_per_tick = 5
can_stack = false
max_stack = 1

燃烧伤害可以比中毒高,但持续时间短一点。

8. 创建减速 Buff

创建:

res://data/buffs/slow.tres

设置:

id = slow
display_name = 减速
description = 移动速度降低
buff_type = SLOW
category = DEBUFF
duration = 3
tick_interval = 1
damage_per_tick = 0
move_speed_multiplier = 0.5
can_stack = false
max_stack = 1

表示:

持续 3
移动速度变成 50%

9. 创建眩晕 Buff

创建:

res://data/buffs/stun.tres

设置:

id = stun
display_name = 眩晕
description = 无法移动和攻击
buff_type = STUN
category = DEBUFF
duration = 1.5
tick_interval = 1
can_stack = false
max_stack = 1

眩晕不需要 tick。

它主要影响:

能不能移动
能不能攻击
能不能释放技能

10. 创建攻击提升 Buff

创建:

res://data/buffs/attack_up.tres

设置:

id = attack_up
display_name = 攻击提升
description = 临时提高攻击力
buff_type = ATTACK_UP
category = BUFF
duration = 8
attack_bonus = 5
can_stack = false
max_stack = 1

11. 创建防御提升 Buff

创建:

res://data/buffs/defense_up.tres

设置:

id = defense_up
display_name = 防御提升
description = 临时提高防御力
buff_type = DEFENSE_UP
category = BUFF
duration = 8
defense_bonus = 3
can_stack = false
max_stack = 1

12. ActiveBuff 是运行中的 Buff

BuffData 是定义。

但角色身上的 Buff 需要记录运行状态,比如:

还剩几秒
当前 tick 计时器到哪里了
当前叠了几层
来源是谁

创建:

res://scripts/buffs/active_buff.gd
class_name ActiveBuff
extends RefCounted

var data: BuffData
var remaining_time: float = 0.0
var tick_timer: float = 0.0
var stack_count: int = 1
var source: Node = null

func _init(p_data: BuffData = null, p_source: Node = null):
    data = p_data
    source = p_source

    if data != null:
        remaining_time = data.duration
        tick_timer = data.tick_interval

func refresh_duration():
    if data == null:
        return

    remaining_time = data.duration
    tick_timer = data.tick_interval

func add_stack():
    if data == null:
        return

    stack_count = min(stack_count + 1, data.max_stack)
    refresh_duration()

13. BuffData 和 ActiveBuff 的区别

非常重要:

BuffData
这是一个什么状态

ActiveBuff
这个状态正在某个角色身上运行到什么程度了

比如:

BuffData poison
中毒持续 6每秒掉 3

ActiveBuff
某只史莱姆身上的中毒还剩 2.3已经 tick 3

这和任务系统很像:

QuestData = 任务定义
QuestState = 玩家当前任务状态

BuffData = Buff 定义
ActiveBuff = 当前运行状态

14. BuffComponent 是什么

BuffComponent 负责管理一个角色身上的所有 Buff。

它要做:

添加 Buff
刷新 Buff
移除 Buff
每帧减少持续时间
每隔 tick_interval 触发效果
计算攻击加成
计算防御加成
计算移动速度倍率
判断是否眩晕
发信号通知 UI

创建:

res://scripts/buffs/buff_component.gd

15. BuffComponent 基础结构

class_name BuffComponent
extends Node

signal buff_added(active_buff: ActiveBuff)
signal buff_removed(active_buff: ActiveBuff)
signal buff_updated(active_buff: ActiveBuff)
signal buffs_changed

@export var owner_node: Node

var active_buffs: Dictionary = {}

这里的 owner_node 是 Buff 持有者。

比如:

Player
Slime
Boss

如果不手动设置,也可以默认取父节点。

func _ready():
    if owner_node == null:
        owner_node = get_parent()

16. active_buffs 的结构

active_buffs[buff_id] = ActiveBuff

比如:

active_buffs["poison"] = ActiveBuff
active_buffs["slow"] = ActiveBuff

这样查找是否已有某个 Buff 非常方便。

17. add_buff()

func add_buff(buff_data: BuffData, source: Node = null):
    if buff_data == null:
        return

    if buff_data.id == "":
        push_warning("BuffData id 不能为空")
        return

    if active_buffs.has(buff_data.id):
        var active_buff: ActiveBuff = active_buffs[buff_data.id]

        if buff_data.can_stack:
            active_buff.add_stack()
        else:
            active_buff.refresh_duration()

        buff_updated.emit(active_buff)
        buffs_changed.emit()
        apply_stat_refresh()
        return

    var new_buff := ActiveBuff.new(buff_data, source)
    active_buffs[buff_data.id] = new_buff

    buff_added.emit(new_buff)
    buffs_changed.emit()
    apply_stat_refresh()

逻辑:

如果身上已经有这个 Buff
  如果可叠加就增加层数
  如果不可叠加就刷新持续时间

如果没有
  新建 ActiveBuff
  加入 active_buffs

第一版这样已经很够用。

18. remove_buff()

func remove_buff(buff_id: String):
    if not active_buffs.has(buff_id):
        return

    var active_buff: ActiveBuff = active_buffs[buff_id]
    active_buffs.erase(buff_id)

    buff_removed.emit(active_buff)
    buffs_changed.emit()
    apply_stat_refresh()

19. clear_all_buffs()

func clear_all_buffs():
    active_buffs.clear()
    buffs_changed.emit()
    apply_stat_refresh()

死亡、读档、新游戏时可能会用到。

20. _process 更新 Buff

func _process(delta):
    update_buffs(delta)
func update_buffs(delta: float):
    var expired_buff_ids: Array[String] = []

    for buff_id in active_buffs.keys():
        var active_buff: ActiveBuff = active_buffs[buff_id]

        active_buff.remaining_time -= delta
        active_buff.tick_timer -= delta

        if active_buff.tick_timer <= 0:
            apply_tick(active_buff)
            active_buff.tick_timer += active_buff.data.tick_interval

        buff_updated.emit(active_buff)

        if active_buff.remaining_time <= 0:
            expired_buff_ids.append(buff_id)

    for buff_id in expired_buff_ids:
        remove_buff(buff_id)

这里用 expired_buff_ids,不要在遍历 Dictionary 的时候直接删。

否则容易出现遍历异常或跳过元素。

21. apply_tick()

每次 Tick 触发持续效果。

func apply_tick(active_buff: ActiveBuff):
    if active_buff == null:
        return

    if active_buff.data == null:
        return

    var data = active_buff.data
    var stack = active_buff.stack_count

    if data.damage_per_tick > 0:
        apply_damage_tick(data.damage_per_tick * stack, active_buff.source)

    if data.heal_per_tick > 0:
        apply_heal_tick(data.heal_per_tick * stack)

22. apply_damage_tick()

func apply_damage_tick(amount: int, source: Node = null):
    if owner_node == null:
        return

    if owner_node.has_method("take_damage"):
        owner_node.take_damage(amount, source)

中毒、燃烧就会调用目标自己的 take_damage()

这样 Player 和 Enemy 都能复用。

23. apply_heal_tick()

func apply_heal_tick(amount: int):
    if owner_node == null:
        return

    if owner_node.has_method("heal"):
        owner_node.heal(amount)

持续回血 Buff 就可以用这个。

24. apply_stat_refresh()

Buff 影响属性后,角色需要重新计算属性。

func apply_stat_refresh():
    if owner_node == null:
        return

    if owner_node.has_method("recalculate_stats"):
        owner_node.recalculate_stats()

Player 前面已经有 recalculate_stats()

Enemy 也可以加一个简化版。

25. 获取攻击加成

func get_attack_bonus() -> int:
    var total := 0

    for active_buff in active_buffs.values():
        if active_buff.data == null:
            continue

        total += active_buff.data.attack_bonus * active_buff.stack_count

    return total

26. 获取防御加成

func get_defense_bonus() -> int:
    var total := 0

    for active_buff in active_buffs.values():
        if active_buff.data == null:
            continue

        total += active_buff.data.defense_bonus * active_buff.stack_count

    return total

27. 获取移动速度倍率

移动速度倍率一般是多个 Buff 相乘。

比如:

减速 50%
另一个减速 80%

最终速度 = 基础速度 * 0.5 * 0.8 = 40%

代码:

func get_move_speed_multiplier() -> float:
    var multiplier := 1.0

    for active_buff in active_buffs.values():
        if active_buff.data == null:
            continue

        multiplier *= active_buff.data.move_speed_multiplier

    return multiplier

28. 判断是否眩晕

func is_stunned() -> bool:
    for active_buff in active_buffs.values():
        if active_buff.data == null:
            continue

        if active_buff.data.buff_type == BuffData.BuffType.STUN:
            return true

    return false

29. 判断是否有某个 Buff

func has_buff(buff_id: String) -> bool:
    return active_buffs.has(buff_id)

30. BuffComponent 完整基础版

class_name BuffComponent
extends Node

signal buff_added(active_buff: ActiveBuff)
signal buff_removed(active_buff: ActiveBuff)
signal buff_updated(active_buff: ActiveBuff)
signal buffs_changed

@export var owner_node: Node

var active_buffs: Dictionary = {}

func _ready():
    if owner_node == null:
        owner_node = get_parent()

func _process(delta):
    update_buffs(delta)

func add_buff(buff_data: BuffData, source: Node = null):
    if buff_data == null:
        return

    if buff_data.id == "":
        push_warning("BuffData id 不能为空")
        return

    if active_buffs.has(buff_data.id):
        var active_buff: ActiveBuff = active_buffs[buff_data.id]

        if buff_data.can_stack:
            active_buff.add_stack()
        else:
            active_buff.refresh_duration()

        buff_updated.emit(active_buff)
        buffs_changed.emit()
        apply_stat_refresh()
        return

    var new_buff := ActiveBuff.new(buff_data, source)
    active_buffs[buff_data.id] = new_buff

    buff_added.emit(new_buff)
    buffs_changed.emit()
    apply_stat_refresh()

func remove_buff(buff_id: String):
    if not active_buffs.has(buff_id):
        return

    var active_buff: ActiveBuff = active_buffs[buff_id]
    active_buffs.erase(buff_id)

    buff_removed.emit(active_buff)
    buffs_changed.emit()
    apply_stat_refresh()

func clear_all_buffs():
    active_buffs.clear()
    buffs_changed.emit()
    apply_stat_refresh()

func update_buffs(delta: float):
    var expired_buff_ids: Array[String] = []

    for buff_id in active_buffs.keys():
        var active_buff: ActiveBuff = active_buffs[buff_id]

        active_buff.remaining_time -= delta
        active_buff.tick_timer -= delta

        if active_buff.tick_timer <= 0:
            apply_tick(active_buff)
            active_buff.tick_timer += active_buff.data.tick_interval

        buff_updated.emit(active_buff)

        if active_buff.remaining_time <= 0:
            expired_buff_ids.append(buff_id)

    for buff_id in expired_buff_ids:
        remove_buff(buff_id)

func apply_tick(active_buff: ActiveBuff):
    if active_buff == null:
        return

    if active_buff.data == null:
        return

    var data = active_buff.data
    var stack = active_buff.stack_count

    if data.damage_per_tick > 0:
        apply_damage_tick(data.damage_per_tick * stack, active_buff.source)

    if data.heal_per_tick > 0:
        apply_heal_tick(data.heal_per_tick * stack)

func apply_damage_tick(amount: int, source: Node = null):
    if owner_node == null:
        return

    if owner_node.has_method("take_damage"):
        owner_node.take_damage(amount, source)

func apply_heal_tick(amount: int):
    if owner_node == null:
        return

    if owner_node.has_method("heal"):
        owner_node.heal(amount)

func apply_stat_refresh():
    if owner_node == null:
        return

    if owner_node.has_method("recalculate_stats"):
        owner_node.recalculate_stats()

func get_attack_bonus() -> int:
    var total := 0

    for active_buff in active_buffs.values():
        if active_buff.data == null:
            continue

        total += active_buff.data.attack_bonus * active_buff.stack_count

    return total

func get_defense_bonus() -> int:
    var total := 0

    for active_buff in active_buffs.values():
        if active_buff.data == null:
            continue

        total += active_buff.data.defense_bonus * active_buff.stack_count

    return total

func get_move_speed_multiplier() -> float:
    var multiplier := 1.0

    for active_buff in active_buffs.values():
        if active_buff.data == null:
            continue

        multiplier *= active_buff.data.move_speed_multiplier

    return multiplier

func is_stunned() -> bool:
    for active_buff in active_buffs.values():
        if active_buff.data == null:
            continue

        if active_buff.data.buff_type == BuffData.BuffType.STUN:
            return true

    return false

func has_buff(buff_id: String) -> bool:
    return active_buffs.has(buff_id)

31. Player 接入 BuffComponent

Player.gd:

@onready var buff_component: BuffComponent = $BuffComponent

移动速度计算:

原来可能是:

velocity = input_direction * move_speed

现在改成:

var speed_multiplier := 1.0

if buff_component != null:
    speed_multiplier = buff_component.get_move_speed_multiplier()

velocity = input_direction * move_speed * speed_multiplier

32. Player 判断眩晕

如果眩晕,不能移动、攻击、放技能。

在输入或物理处理中加:

func is_stunned() -> bool:
    if buff_component == null:
        return false

    return buff_component.is_stunned()

移动时:

func physics_walk(delta):
    if is_stunned():
        velocity = Vector2.ZERO
        move_and_slide()
        update_animation(Vector2.ZERO)
        return

    # 原来的移动逻辑

攻击时:

func start_attack():
    if is_stunned():
        return

    # 原来的攻击逻辑

释放技能时:

func cast_skill(skill: SkillData) -> bool:
    if is_stunned():
        print("眩晕中,无法释放技能")
        return false

    # 原来的 cast_skill

33. Player 属性计算加入 Buff 加成

前面 Player 的 recalculate_stats() 大概是:

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)

现在加 Buff:

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

    if buff_component != null:
        final_attack += buff_component.get_attack_bonus()
        final_defense += buff_component.get_defense_bonus()

    current_hp = min(current_hp, max_hp)

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

这样攻击提升、防御提升就生效了。

34. Enemy 接入 BuffComponent

Enemy 也加:

Enemy
├── BuffComponent
└── ...

Enemy.gd:

@onready var buff_component: BuffComponent = $BuffComponent

移动速度:

func get_current_move_speed() -> float:
    var multiplier := 1.0

    if buff_component != null:
        multiplier = buff_component.get_move_speed_multiplier()

    return move_speed * multiplier

追击玩家时:

velocity = direction * get_current_move_speed()

35. Enemy 判断眩晕

func is_stunned() -> bool:
    if buff_component == null:
        return false

    return buff_component.is_stunned()

_physics_process 或状态逻辑里:

if is_stunned():
    velocity = Vector2.ZERO
    move_and_slide()
    play_animation("idle")
    return

攻击时:

func start_attack():
    if is_stunned():
        return

    # 原来的攻击

这样眩晕对怪物就生效了。

36. Enemy 防御加成

如果 Enemy 也有防御,可以在 take_damage() 里加入 Buff 防御。

@export var base_defense: int = 0
var final_defense: int = 0
func recalculate_stats():
    final_defense = base_defense

    if buff_component != null:
        final_defense += buff_component.get_defense_bonus()

受伤:

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

    var final_damage = max(amount - final_defense, 1)

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

    # 后面原逻辑

第一版 Enemy 也可以先不做防御,只让 Buff 影响移动和持续伤害。

37. 技能如何施加 Buff

SkillData 可以加:

@export var applied_buffs: Array[BuffData] = []
@export var buff_apply_chance: float = 1.0

修改:

res://scripts/skills/skill_data.gd

加入:

@export var applied_buffs: Array[BuffData] = []
@export_range(0.0, 1.0) var buff_apply_chance: float = 1.0

比如火球术:

applied_buffs = [burn.tres]
buff_apply_chance = 1.0

冰霜新星:

applied_buffs = [slow.tres]
buff_apply_chance = 1.0

38. SkillHitbox 支持施加 Buff

SkillHitbox.gd 增加:

var applied_buffs: Array[BuffData] = []
var buff_apply_chance: float = 1.0

setup 改成:

func setup(
    p_damage: int,
    p_attacker: Node,
    p_applied_buffs: Array[BuffData] = [],
    p_buff_apply_chance: float = 1.0
):
    damage = p_damage
    attacker = p_attacker
    applied_buffs = p_applied_buffs
    buff_apply_chance = p_buff_apply_chance

命中后:

func _on_area_entered(area: Area2D):
    if hit_targets.has(area):
        return

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

    hit_targets.append(area)

    if area.has_method("take_damage"):
        area.take_damage(damage, attacker)

    apply_buffs_to_hurtbox(area)

添加:

func apply_buffs_to_hurtbox(area: Area2D):
    if applied_buffs.is_empty():
        return

    var owner_node = area.get_parent()

    if owner_node == null:
        return

    var buff_component = owner_node.get_node_or_null("BuffComponent")

    if buff_component == null:
        return

    for buff_data in applied_buffs:
        if randf() <= buff_apply_chance:
            buff_component.add_buff(buff_data, attacker)

39. Projectile 支持施加 Buff

Projectile.gd 同样加:

var applied_buffs: Array[BuffData] = []
var buff_apply_chance: float = 1.0

setup:

func setup(
    p_direction: Vector2,
    p_damage: int,
    p_attacker: Node,
    p_applied_buffs: Array[BuffData] = [],
    p_buff_apply_chance: float = 1.0
):
    direction = p_direction.normalized()
    damage = p_damage
    attacker = p_attacker
    applied_buffs = p_applied_buffs
    buff_apply_chance = p_buff_apply_chance

命中:

func _on_area_entered(area: Area2D):
    if hit_targets.has(area):
        return

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

    hit_targets.append(area)

    if area.has_method("take_damage"):
        area.take_damage(damage, attacker)

    apply_buffs_to_hurtbox(area)

    queue_free()

apply_buffs_to_hurtbox() 可以和 SkillHitbox 一样。

后面可以抽公共函数,现在先复制也行。

40. AOE 支持施加 Buff

AOEAttack.gd:

var applied_buffs: Array[BuffData] = []
var buff_apply_chance: float = 1.0

setup:

func setup(
    p_damage: int,
    p_attacker: Node,
    radius: float,
    p_applied_buffs: Array[BuffData] = [],
    p_buff_apply_chance: float = 1.0
):
    damage = p_damage
    attacker = p_attacker
    applied_buffs = p_applied_buffs
    buff_apply_chance = p_buff_apply_chance

    var collision_shape: CollisionShape2D = $CollisionShape2D

    if collision_shape.shape is CircleShape2D:
        collision_shape.shape.radius = radius

hit_area 里:

func hit_area(area: Area2D):
    if hit_targets.has(area):
        return

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

    hit_targets.append(area)

    if area.has_method("take_damage"):
        area.take_damage(damage, attacker)

    apply_buffs_to_hurtbox(area)

41. Player 释放技能时传 Buff

Player.gd 里修改近战技能:

func execute_melee_skill(skill: SkillData):
    if skill_hitbox_scene == null:
        print("没有设置 skill_hitbox_scene")
        return

    var hitbox = skill_hitbox_scene.instantiate()
    get_skill_parent().add_child(hitbox)

    hitbox.global_position = get_melee_skill_position(skill)

    if hitbox.has_method("setup"):
        hitbox.setup(
            skill.damage + final_attack,
            self,
            skill.applied_buffs,
            skill.buff_apply_chance
        )

远程:

func execute_projectile_skill(skill: SkillData):
    if skill.projectile_scene == null:
        print("技能没有 projectile_scene:", skill.display_name)
        return

    var projectile = skill.projectile_scene.instantiate()
    get_skill_parent().add_child(projectile)

    var direction = last_direction.normalized()

    if direction == Vector2.ZERO:
        direction = Vector2.DOWN

    projectile.global_position = global_position + direction * 24

    if projectile.has_method("setup"):
        projectile.setup(
            direction,
            skill.damage + final_attack,
            self,
            skill.applied_buffs,
            skill.buff_apply_chance
        )

AOE:

func execute_aoe_skill(skill: SkillData):
    if skill.aoe_scene == null:
        print("技能没有 aoe_scene:", skill.display_name)
        return

    var aoe = skill.aoe_scene.instantiate()
    get_skill_parent().add_child(aoe)

    aoe.global_position = global_position

    if aoe.has_method("setup"):
        aoe.setup(
            skill.damage + final_attack,
            self,
            skill.radius,
            skill.applied_buffs,
            skill.buff_apply_chance
        )

42. Buff 图标 UI

玩家需要看到自己身上的 Buff。

可以在 PlayerHUD 加:

BuffBar HBoxContainer

结构:

PlayerHUD
├── HPBar
├── ManaBar
├── StaminaBar
├── BuffBar HBoxContainer

每个 Buff 用一个 Label 先显示:

中毒 4.2
燃烧 2.1
攻击提升 7.5

第一版不用图标,先用文字调试。

43. BuffBar.gd 简单版

也可以写在 PlayerHUD.gd 里。

@onready var buff_bar: HBoxContainer = $BuffBar

var player: Node

setup 里:

if player.has_node("BuffComponent"):
    var buff_component: BuffComponent = player.get_node("BuffComponent")
    buff_component.buffs_changed.connect(refresh_buffs)
    buff_component.buff_updated.connect(func(_active_buff):
        refresh_buffs()
    )

    refresh_buffs()

刷新:

func refresh_buffs():
    for child in buff_bar.get_children():
        child.queue_free()

    if player == null:
        return

    var buff_component: BuffComponent = player.get_node_or_null("BuffComponent")

    if buff_component == null:
        return

    for active_buff in buff_component.active_buffs.values():
        var label = Label.new()

        label.text = "%s %.1f" % [
            active_buff.data.display_name,
            active_buff.remaining_time
        ]

        buff_bar.add_child(label)

这个 UI 每次 Buff 更新都会重建,有点粗暴,但第一版够用。

后面可以优化成复用节点。

44. 怪物头顶显示 Buff

第一版可以不做。

如果想做,可以在 Enemy 下面加:

BuffBar HBoxContainer

或者只给 Boss 显示。

普通怪全部显示 Buff 可能会很乱。

建议:

玩家 HUD 显示完整 Buff
怪物只显示明显状态比如燃烧火焰特效眩晕星星

第一版先做玩家 BuffBar。

45. Buff 特效

Buff 可以有视觉反馈:

中毒角色变绿
燃烧火焰粒子
减速蓝色冰霜效果
眩晕头顶星星
攻击提升红色光效
防御提升蓝色护盾

第一版可以简单用 modulate

比如 Enemy 中毒变绿:

func update_buff_visuals():
    if buff_component.has_buff("poison"):
        animated_sprite.modulate = Color(0.6, 1.0, 0.6)
    elif buff_component.has_buff("burn"):
        animated_sprite.modulate = Color(1.0, 0.5, 0.3)
    else:
        animated_sprite.modulate = Color(1, 1, 1)

在 BuffComponent 的 buffs_changed 连接:

buff_component.buffs_changed.connect(update_buff_visuals)

注意:如果角色受伤也会闪红,modulate 可能会互相覆盖。

第一版可以接受,后面再做更好的效果层。

46. Buff 叠加规则

第一版逻辑:

不可叠加
重复添加时刷新持续时间

可叠加
重复添加时 stack +1最多 max_stack
同时刷新持续时间

这已经足够。

比如中毒可以设置:

can_stack = true
max_stack = 3
damage_per_tick = 2

三层中毒就是:

每秒 6 点伤害

不过第一版建议多数 Buff 不叠加。

叠加系统容易让数值起飞,像没拴绳的史莱姆一样乱蹦。

47. Buff 是否需要存档

一般战斗中的临时 Buff 不建议存档。

原因:

中毒剩 2
燃烧剩 1
读档后还要不要继续

多数 RPG 读档后临时状态清空是可以接受的。

所以第一版:

Buff 不存档

如果以后有长期 Buff,比如:

祝福持续一整天
诅咒持续到解除
食物效果持续 10 分钟

那可以单独保存。

现在不做。

48. Buff 和 TimeManager 的关系

战斗 Buff 建议用真实秒数 delta 更新,而不是游戏内分钟。

比如:

中毒 6
眩晕 1.5
燃烧 4

这些是战斗实时效果。

不要用 TimeManager 的游戏时间。

因为游戏里 1 秒可能等于 1 分钟,如果中毒按游戏时间走,那玩家可能一秒掉 60 次血,现场直接变成毒气实验室。

所以:

战斗 Buff 用真实 delta
世界日程天气季节用 TimeManager

49. Buff 和暂停

如果打开菜单或对话时,Buff 是否继续走?

第一版建议:

打开菜单 / 对话时战斗也暂停
Buff 也暂停

最简单做法:

func _process(delta):
    if TimeManager.is_time_paused:
        return

    update_buffs(delta)

但注意:TimeManager 的暂停原本是“游戏时间暂停”,不一定等于战斗暂停。

更严谨以后会做 GameStateManager。

第一版可以先这样:

func _process(delta):
    if TimeManager.is_time_paused:
        return

    update_buffs(delta)

这样打开对话时,中毒不会继续扣血,比较舒服。

50. BuffComponent 暂停版

func _process(delta):
    if TimeManager.is_time_paused:
        return

    update_buffs(delta)

如果你希望战斗中打开背包也能被毒死,那就不要暂停。

但对入门 RPG 来说,暂停比较友好。

51. Buff 和死亡

角色死亡后,Buff 应该清空或停止。

Player 死亡:

func die():
    if buff_component != null:
        buff_component.clear_all_buffs()

    # 原来的死亡逻辑

Enemy 死亡:

func enter_state(state):
    match state:
        EnemyState.DEAD:
            if buff_component != null:
                buff_component.clear_all_buffs()

            give_death_rewards()
            queue_free()

其实 Enemy queue_free 了,BuffComponent 也会消失。

但清空能让逻辑更干净。

52. 技能例子:火球附加燃烧

fireball.tres 里:

applied_buffs = [burn.tres]
buff_apply_chance = 1.0

效果:

火球命中
造成直接伤害
附加燃烧
燃烧每秒掉血
持续 4

53. 技能例子:冰霜新星附加减速

ice_nova.tres 里:

applied_buffs = [slow.tres]
buff_apply_chance = 1.0

效果:

冰霜新星命中周围敌人
造成伤害
敌人移动速度降低 3

54. 技能例子:盾墙附加防御提升

可以创建技能:

defense_shout.tres

类型:

BUFF

不过我们还没实现 execute_buff_skill()

可以先做。

SkillData:

id = defense_shout
display_name = 防御姿态
skill_type = BUFF
cost_type = STAMINA
cost_amount = 15
cooldown = 10
applied_buffs = [defense_up.tres]

Player.gd:

func execute_buff_skill(skill: SkillData):
    if buff_component == null:
        return

    for buff_data in skill.applied_buffs:
        buff_component.add_buff(buff_data, self)

然后 execute_skill() 里别忘了:

SkillData.SkillType.BUFF:
    execute_buff_skill(skill)

55. 技能例子:战吼攻击提升

创建:

attack_shout.tres

设置:

skill_type = BUFF
cost_type = STAMINA
cost_amount = 20
cooldown = 12
applied_buffs = [attack_up.tres]

释放后:

玩家攻击力 +5
持续 8

因为 Player 的 recalculate_stats() 会加入 Buff 攻击加成,所以攻击力会临时提高。

56. 常见错误 1:Buff 加不上

排查:

目标节点是否有 BuffComponent
BuffComponent.owner_node 是否正确
BuffData.id 是否不为空
apply_buffs_to_hurtbox 是否找到 owner_node
Hurtbox 的父节点是不是 Enemy / Player

如果 Hurtbox 不是角色的直接子节点,area.get_parent() 可能拿不到正确角色。

这时建议 Hurtbox.gd 增加:

@export var owner_node: Node

然后 apply buff 时优先取:

var owner_node = area.owner_node

57. 常见错误 2:中毒不掉血

排查:

BuffComponent._process 是否运行
TimeManager.is_time_paused 是否一直 true
damage_per_tick 是否大于 0
tick_interval 是否大于 0
owner_node 是否有 take_damage 方法

临时打印:

print("Buff tick: ", active_buff.data.display_name)

58. 常见错误 3:Buff 不会消失

排查:

remaining_time 是否减少
update_buffs 是否被调用
expired_buff_ids 是否添加了 buff_id
remove_buff 是否执行
duration 是否设置太大

临时打印:

print(active_buff.data.id, active_buff.remaining_time)

59. 常见错误 4:减速不生效

排查:

move_speed_multiplier 是否小于 1
角色移动时是否调用 buff_component.get_move_speed_multiplier()
Enemy 是否也接入了移动倍率
Buff 是否真的加到目标身上

如果你只给 Player 写了减速逻辑,怪物当然不会被减速。

60. 常见错误 5:眩晕不生效

排查:

stun.tres buff_type 是否是 STUN
is_stunned() 是否返回 true
移动攻击释放技能是否都检查了 is_stunned()
Enemy AI 是否在眩晕时提前 return

眩晕不是 BuffComponent 自动让角色不能动。

BuffComponent 只是告诉你:

现在有眩晕

真正不能动,要角色代码配合。

61. 常见错误 6:攻击提升不生效

排查:

attack_up.tres attack_bonus 是否大于 0
BuffComponent.add_buff 后是否调用 apply_stat_refresh
Player.recalculate_stats 是否加入 buff_component.get_attack_bonus()
攻击时是否用 final_attack

如果攻击命中还在用旧的 attack_damage,攻击提升不会生效。

62. 常见错误 7:防御提升不生效

排查:

defense_up.tres defense_bonus 是否大于 0
Player.recalculate_stats 是否加入 Buff 防御
take_damage 是否使用 final_defense

63. 常见错误 8:Buff UI 不刷新

排查:

PlayerHUD 是否拿到 BuffComponent
是否连接 buffs_changed
是否连接 buff_updated
BuffBar 路径是否正确
refresh_buffs 是否执行

临时打印:

print("刷新 Buff UI")

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

BuffData 是状态定义
ActiveBuff 是运行中的状态实例
BuffComponent 管理某个角色身上的所有 Buff
Buff Debuff 可以用同一套系统
持续伤害用 tick_interval
持续时间用 remaining_time
攻击防御速度这类效果需要角色代码配合
眩晕不是自动生效要移动/攻击/技能逻辑主动判断
技能可以通过 applied_buffs 给目标附加 Buff
临时战斗 Buff 第一版不建议存档
战斗 Buff 用真实 delta不要用游戏内时间

65. 你现在的小练习

按这个顺序做:

1. 创建 BuffData.gd
2. 创建 ActiveBuff.gd
3. 创建 BuffComponent.gd
4. Player 添加 BuffComponent
5. Enemy 添加 BuffComponent
6. 创建 poison.tres
7. 创建 burn.tres
8. 创建 slow.tres
9. 创建 stun.tres
10. 创建 attack_up.tres
11. 创建 defense_up.tres
12. BuffComponent 实现 add_buff()
13. BuffComponent 实现 remove_buff()
14. BuffComponent 实现 update_buffs()
15. BuffComponent 实现 apply_tick()
16. 测试手动给 Player 添加 poison
17. 确认 Player 每秒掉血
18. 测试给 Enemy 添加 slow
19. 确认 Enemy 移动速度下降
20. 测试给 Enemy 添加 stun
21. 确认 Enemy 不能移动和攻击
22. Player.recalculate_stats 加入 Buff 攻击/防御
23. 测试 attack_up 提升攻击
24. 测试 defense_up 降低受到伤害
25. SkillData 添加 applied_buffs
26. 火球术附加 burn
27. 冰霜新星附加 slow
28. 创建防御姿态技能给自己添加 defense_up
29. PlayerHUD 添加 BuffBar
30. 显示当前 Buff 名称和剩余时间

完成这一部分后,你的战斗系统就开始有“状态变化”和“战术节奏”了。

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

分享文章

相关文章

更多文章 →
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 官方文档也把节点和场景放在一起讲:多个节点组成树状结构后...
学习

评论

请登录后发表评论

去登录
加载评论中...