第二十六部分:Buff / Debuff 系统基础:中毒、燃烧、减速、眩晕、攻击提升、防御提升、持续时间管理
这一部分开始做 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,比如 poison、burn、slow
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 名称和剩余时间
完成这一部分后,你的战斗系统就开始有“状态变化”和“战术节奏”了。
如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录