第二十五部分:战斗技能系统基础:技能数据、冷却、蓝量/体力、近战技能、远程技能、AOE 技能
这一部分开始升级战斗系统。
前面我们的战斗已经有:
玩家普通攻击
怪物受伤
怪物追击
怪物攻击玩家
血量
装备
等级
经验
但现在玩家的战斗方式还比较单一:
按攻击键
挥一下武器
造成伤害
现在我们要做技能系统,比如:
横扫斩
火球术
治疗术
翻滚
冰霜新星
范围爆炸
冲刺斩
技能系统会让战斗从“平砍”变成“有选择、有节奏、有资源管理”。
1. 技能系统要解决什么
技能系统主要解决:
技能是什么?
技能造成多少伤害?
技能消耗什么资源?
技能有没有冷却?
技能是近战还是远程?
技能是单体还是范围?
技能如何命中目标?
技能释放时播放什么动画?
技能释放后生成什么效果?
比如一个火球术:
技能名:火球术
类型:远程
伤害:25
消耗:10 蓝量
冷却:2 秒
生成物:FireballProjectile
命中后爆炸
比如一个横扫斩:
技能名:横扫斩
类型:近战
伤害:18
消耗:15 体力
冷却:1.5 秒
攻击范围:玩家前方扇形/矩形
比如一个治疗术:
技能名:治疗术
类型:自我恢复
恢复:30
消耗:15 蓝量
冷却:5 秒
第一版不要做得太复杂。
先做:
技能数据
释放技能
资源消耗
冷却
近战技能
远程 projectile 技能
AOE 范围技能
2. 第一版技能系统目标
第一版建议做这些:
1. SkillData Resource 定义技能
2. Player 有 mana / stamina
3. 技能可以消耗 mana 或 stamina
4. 技能有 cooldown
5. Player 按键释放技能
6. 技能冷却期间不能释放
7. 近战技能用 Area2D 检测命中
8. 远程技能生成 Projectile
9. AOE 技能生成范围伤害
10. UI 显示技能冷却和资源
先不要做:
技能树
技能升级
连招系统
吟唱条
打断机制
元素反应
复杂 Buff/Debuff
目标锁定
自动瞄准
技能编辑器
第一版核心是:
按键放技能
技能有消耗和冷却
技能能造成不同形式的伤害
3. SkillData 为什么也适合用 Resource
技能和物品、任务一样,本质是数据。
所以适合用 Resource 保存。
比如:
fireball.tres
slash.tres
heal.tres
ice_nova.tres
Godot 的 Resource 本身就适合作为数据容器,自定义 Resource 可以通过 class_name 和导出属性在 Inspector 中创建、编辑并保存成 .tres 文件,这类做法很适合物品、任务、技能这类配置数据。(Godot Engine documentation)
创建:
res://scripts/skills/skill_data.gd
4. SkillData.gd
class_name SkillData
extends Resource
enum SkillType {
MELEE,
PROJECTILE,
AOE,
HEAL,
BUFF,
DASH
}
enum CostType {
NONE,
MANA,
STAMINA,
HP
}
@export var id: String = ""
@export var display_name: String = ""
@export_multiline var description: String = ""
@export var icon: Texture2D
@export var skill_type: SkillType = SkillType.MELEE
@export var damage: int = 10
@export var heal_amount: int = 0
@export var cost_type: CostType = CostType.NONE
@export var cost_amount: int = 0
@export var cooldown: float = 1.0
@export var range: float = 48.0
@export var radius: float = 32.0
@export var projectile_scene: PackedScene
@export var aoe_scene: PackedScene
@export var animation_name: String = ""
字段解释:
id:
技能唯一 ID,比如 fireball
display_name:
玩家看到的名字,比如 火球术
description:
技能说明
icon:
技能图标
skill_type:
技能类型
damage:
伤害值
heal_amount:
治疗量
cost_type:
消耗资源类型
cost_amount:
消耗数量
cooldown:
冷却时间,单位秒
range:
技能距离,远程技能或冲刺技能会用
radius:
范围技能半径
projectile_scene:
远程技能生成的 projectile 场景
aoe_scene:
范围技能生成的 AOE 场景
animation_name:
释放技能时播放的动画名
5. 为什么 SkillData 不直接写逻辑
你可能会想:
火球术逻辑直接写在 fireball.tres 里?
治疗术逻辑直接写在 heal.tres 里?
第一版不建议。
SkillData 先只保存数据。
真正执行逻辑放到:
SkillCaster
Player
Projectile
AOE
这样分工更清楚:
SkillData:
技能配置
SkillCaster:
释放技能
Projectile:
飞行和命中
AOE:
范围检测和伤害
不要让数据资源变成“半数据半逻辑”的万能怪物。
6. 创建第一个技能:横扫斩
创建:
res://data/skills/slash.tres
类型:
SkillData
设置:
id = slash
display_name = 横扫斩
description = 向前方挥出强力一击,造成 18 点伤害。
skill_type = MELEE
damage = 18
cost_type = STAMINA
cost_amount = 10
cooldown = 1.2
range = 40
radius = 0
animation_name = slash
7. 创建第二个技能:火球术
创建:
res://data/skills/fireball.tres
设置:
id = fireball
display_name = 火球术
description = 发射一枚火球,命中敌人造成 25 点伤害。
skill_type = PROJECTILE
damage = 25
cost_type = MANA
cost_amount = 12
cooldown = 2.0
range = 220
projectile_scene = FireballProjectile.tscn
animation_name = cast
8. 创建第三个技能:治疗术
创建:
res://data/skills/heal.tres
设置:
id = heal
display_name = 治疗术
description = 恢复 30 点生命值。
skill_type = HEAL
damage = 0
heal_amount = 30
cost_type = MANA
cost_amount = 15
cooldown = 5.0
animation_name = cast
9. 创建第四个技能:冰霜新星
创建:
res://data/skills/ice_nova.tres
设置:
id = ice_nova
display_name = 冰霜新星
description = 对周围敌人造成范围伤害。
skill_type = AOE
damage = 20
cost_type = MANA
cost_amount = 20
cooldown = 6.0
radius = 64
aoe_scene = AOEAttack.tscn
animation_name = cast
10. Player 需要 mana 和 stamina
之前 Player 已经有:
hp
gold
exp
equipment
现在加:
signal mana_changed(current_mana: int, max_mana: int)
signal stamina_changed(current_stamina: int, max_stamina: int)
@export var max_mana: int = 50
@export var max_stamina: int = 100
var current_mana: int = 50
var current_stamina: int = 100
初始化:
func initialize_resources():
current_mana = max_mana
current_stamina = max_stamina
mana_changed.emit(current_mana, max_mana)
stamina_changed.emit(current_stamina, max_stamina)
如果你已经有 _ready(),把它合并进去。
11. 消耗资源
Player.gd:
func has_enough_resource(skill: SkillData) -> bool:
if skill == null:
return false
match skill.cost_type:
SkillData.CostType.NONE:
return true
SkillData.CostType.MANA:
return current_mana >= skill.cost_amount
SkillData.CostType.STAMINA:
return current_stamina >= skill.cost_amount
SkillData.CostType.HP:
return current_hp > skill.cost_amount
return false
注意 HP 消耗:
return current_hp > skill.cost_amount
不是:
current_hp >= skill.cost_amount
因为一般不希望技能把自己放死。
当然,如果你想做献祭技能,那另说,黑魔法那边请排队。
12. spend_skill_cost()
func spend_skill_cost(skill: SkillData) -> bool:
if not has_enough_resource(skill):
return false
match skill.cost_type:
SkillData.CostType.NONE:
pass
SkillData.CostType.MANA:
current_mana -= skill.cost_amount
mana_changed.emit(current_mana, max_mana)
SkillData.CostType.STAMINA:
current_stamina -= skill.cost_amount
stamina_changed.emit(current_stamina, max_stamina)
SkillData.CostType.HP:
current_hp -= skill.cost_amount
health_changed.emit(current_hp, max_hp)
return true
释放技能时先检查,再消耗。
13. 资源恢复
体力通常会自动恢复。
蓝量可以慢慢恢复,也可以靠药水恢复。
第一版建议:
stamina 自动恢复
mana 不自动恢复,靠药水或休息
Player.gd:
@export var stamina_regen_per_second: float = 15.0
var stamina_regen_accumulator: float = 0.0
在 _process(delta):
func _process(delta):
regenerate_stamina(delta)
func regenerate_stamina(delta):
if current_stamina >= max_stamina:
return
stamina_regen_accumulator += stamina_regen_per_second * delta
if stamina_regen_accumulator >= 1.0:
var amount := int(stamina_regen_accumulator)
stamina_regen_accumulator -= amount
current_stamina = min(current_stamina + amount, max_stamina)
stamina_changed.emit(current_stamina, max_stamina)
这样体力会平滑恢复。
14. 技能冷却怎么存
Player 需要记录每个技能还剩多少冷却。
用 Dictionary:
var skill_cooldowns: Dictionary = {}
结构:
skill_cooldowns["slash"] = 0.5
skill_cooldowns["fireball"] = 1.2
每帧减少:
func update_skill_cooldowns(delta):
for skill_id in skill_cooldowns.keys():
var remaining: float = skill_cooldowns[skill_id]
remaining -= delta
if remaining <= 0:
skill_cooldowns[skill_id] = 0.0
else:
skill_cooldowns[skill_id] = remaining
在 _process(delta) 里:
func _process(delta):
regenerate_stamina(delta)
update_skill_cooldowns(delta)
15. 检查技能是否冷却完成
func is_skill_ready(skill: SkillData) -> bool:
if skill == null:
return false
return get_skill_cooldown_remaining(skill.id) <= 0.0
func get_skill_cooldown_remaining(skill_id: String) -> float:
return skill_cooldowns.get(skill_id, 0.0)
开始冷却:
func start_skill_cooldown(skill: SkillData):
skill_cooldowns[skill.id] = skill.cooldown
16. Player 装备技能
第一版先让 Player 直接导出几个技能槽。
@export var skill_1: SkillData
@export var skill_2: SkillData
@export var skill_3: SkillData
@export var skill_4: SkillData
以后可以做技能栏、技能树、拖拽配置。
现在先用 Inspector 拖资源。
17. 输入绑定
InputMap 添加:
skill_1
skill_2
skill_3
skill_4
绑定:
1
2
3
4
Player 输入:
func handle_normal_input(event):
if event.is_action_pressed("skill_1"):
cast_skill(skill_1)
return
if event.is_action_pressed("skill_2"):
cast_skill(skill_2)
return
if event.is_action_pressed("skill_3"):
cast_skill(skill_3)
return
if event.is_action_pressed("skill_4"):
cast_skill(skill_4)
return
如果你已有攻击、交互、药水输入,就把它合并进去。
18. cast_skill()
核心函数:
func cast_skill(skill: SkillData) -> bool:
if skill == null:
return false
if current_state == PlayerState.DEAD:
return false
if current_state == PlayerState.HURT:
return false
if not is_skill_ready(skill):
print("技能冷却中:", skill.display_name)
return false
if not has_enough_resource(skill):
print("资源不足:", skill.display_name)
return false
if not spend_skill_cost(skill):
return false
start_skill_cooldown(skill)
execute_skill(skill)
return true
顺序很重要:
技能是否存在
玩家是否能施法
冷却是否完成
资源是否足够
消耗资源
开始冷却
执行技能
19. execute_skill()
func execute_skill(skill: SkillData):
if skill.animation_name != "":
play_skill_animation(skill.animation_name)
match skill.skill_type:
SkillData.SkillType.MELEE:
execute_melee_skill(skill)
SkillData.SkillType.PROJECTILE:
execute_projectile_skill(skill)
SkillData.SkillType.AOE:
execute_aoe_skill(skill)
SkillData.SkillType.HEAL:
execute_heal_skill(skill)
SkillData.SkillType.BUFF:
execute_buff_skill(skill)
SkillData.SkillType.DASH:
execute_dash_skill(skill)
先实现前四个:
MELEE
PROJECTILE
AOE
HEAL
Buff 和 Dash 后面再讲。
20. play_skill_animation()
func play_skill_animation(anim_name: String):
if animated_sprite == null:
return
if animated_sprite.sprite_frames == null:
return
if animated_sprite.sprite_frames.has_animation(anim_name):
animated_sprite.play(anim_name)
如果你有方向动画,可以做:
slash_down
slash_up
slash_left
slash_right
第一版先统一 slash 或 cast。
21. 近战技能怎么做
近战技能有两种做法。
做法 A:
复用 Player 的 AttackArea
临时调整伤害和范围
做法 B:
生成一个 SkillHitbox Area2D
存在 0.1 秒
检测敌人
造成伤害
然后消失
第一版推荐做法 B。
因为不同技能的范围、位置、持续时间都可能不同。
技能命中盒作为独立场景会更灵活。
22. SkillHitbox 场景
创建:
res://scenes/skills/skill_hitbox.tscn
结构:
SkillHitbox Area2D
└── CollisionShape2D
Area2D 是 Godot 里表示 2D 区域的节点,可以检测其他 CollisionObject2D 进入、退出或重叠,非常适合做攻击判定、触发区和技能范围。(Godot Engine documentation)
SkillHitbox.gd:
extends Area2D
@export var lifetime: float = 0.12
var damage: int = 0
var attacker: Node = null
var hit_targets: Array[Node] = []
func _ready():
area_entered.connect(_on_area_entered)
await get_tree().create_timer(lifetime).timeout
queue_free()
func setup(p_damage: int, p_attacker: Node):
damage = p_damage
attacker = p_attacker
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)
23. 近战技能生成 hitbox
Player.gd:
@export var skill_hitbox_scene: PackedScene
func execute_melee_skill(skill: SkillData):
if skill_hitbox_scene == null:
print("没有设置 skill_hitbox_scene")
return
var hitbox = skill_hitbox_scene.instantiate()
get_tree().current_scene.add_child(hitbox)
hitbox.global_position = get_melee_skill_position(skill)
if hitbox.has_method("setup"):
hitbox.setup(skill.damage + final_attack, self)
注意伤害:
skill.damage + final_attack
意思是:
技能基础伤害 + 玩家最终攻击力
你也可以设计成:
技能伤害直接等于 skill.damage
或者:
final_attack * 技能倍率
第一版用加法比较直观。
24. get_melee_skill_position()
你之前 Player 应该有 last_direction。
比如:
var last_direction: Vector2 = Vector2.DOWN
func get_melee_skill_position(skill: SkillData) -> Vector2:
var direction = last_direction.normalized()
if direction == Vector2.ZERO:
direction = Vector2.DOWN
return global_position + direction * skill.range
这样横扫斩会生成在玩家面前。
25. SkillHitbox 的形状
SkillHitbox 的 CollisionShape2D 可以先用:
RectangleShape2D
大小:
48 x 32
如果你想根据技能控制范围,可以在 setup 里调整 shape。
比如:
func setup(p_damage: int, p_attacker: Node, size: Vector2 = Vector2(48, 32)):
damage = p_damage
attacker = p_attacker
var collision_shape: CollisionShape2D = $CollisionShape2D
if collision_shape.shape is RectangleShape2D:
collision_shape.shape.size = size
第一版先固定也行。
26. 远程技能:Projectile
远程技能最常见就是生成一个飞行物。
结构:
FireballProjectile Area2D
├── Sprite2D
└── CollisionShape2D
Projectile.gd:
extends Area2D
@export var speed: float = 220.0
@export var lifetime: float = 2.0
var direction: Vector2 = Vector2.RIGHT
var damage: int = 0
var attacker: Node = null
var hit_targets: Array[Node] = []
func _ready():
area_entered.connect(_on_area_entered)
await get_tree().create_timer(lifetime).timeout
queue_free()
func setup(p_direction: Vector2, p_damage: int, p_attacker: Node):
direction = p_direction.normalized()
damage = p_damage
attacker = p_attacker
func _physics_process(delta):
global_position += direction * speed * delta
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)
queue_free()
27. execute_projectile_skill()
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_tree().current_scene.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)
这样按技能键会从玩家前方发射火球。
28. Projectile 的碰撞层
火球应该检测敌人的 Hurtbox。
建议:
Projectile:
collision_layer = 技能层
collision_mask = enemy_hurtbox 层
EnemyHurtbox:
加入 group hurtbox
第一版可以先靠 group 判断。
但碰撞层要设置对,否则 area_entered 根本不会触发。
常见排查:
Projectile 是 Area2D 吗?
Projectile 有 CollisionShape2D 吗?
CollisionShape2D 有 shape 吗?
Projectile monitoring 是否开启?
Enemy Hurtbox monitorable 是否开启?
collision mask 是否匹配?
29. AOE 技能
AOE 技能就是生成一个范围区域,检测区域内所有敌人,然后造成伤害。
结构:
AOEAttack Area2D
├── CollisionShape2D
├── AnimatedSprite2D 或 Sprite2D
└── Timer 可选
AOEAttack.gd:
extends Area2D
@export var lifetime: float = 0.3
var damage: int = 0
var attacker: Node = null
var hit_targets: Array[Node] = []
func _ready():
area_entered.connect(_on_area_entered)
await get_tree().create_timer(lifetime).timeout
queue_free()
func setup(p_damage: int, p_attacker: Node, radius: float):
damage = p_damage
attacker = p_attacker
var collision_shape: CollisionShape2D = $CollisionShape2D
if collision_shape.shape is CircleShape2D:
collision_shape.shape.radius = radius
func _on_area_entered(area: Area2D):
hit_area(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)
30. AOE 初始化时要检测已在范围内的敌人
有个细节:
AOE 生成时,敌人可能已经在范围内
area_entered 不一定会对已经重叠的 Area 立即触发
所以 _ready() 里可以等一帧,然后主动检查:
func _ready():
area_entered.connect(_on_area_entered)
await get_tree().process_frame
for area in get_overlapping_areas():
hit_area(area)
await get_tree().create_timer(lifetime).timeout
queue_free()
get_overlapping_areas() 可以拿到当前正在重叠的 Area2D。
这样 AOE 更稳定。
31. execute_aoe_skill()
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_tree().current_scene.add_child(aoe)
aoe.global_position = global_position
if aoe.has_method("setup"):
aoe.setup(skill.damage + final_attack, self, skill.radius)
冰霜新星这种以玩家为中心的技能,就放在玩家位置。
如果是鼠标位置释放,后面再扩展。
32. 治疗技能
func execute_heal_skill(skill: SkillData):
if skill.heal_amount <= 0:
return
heal(skill.heal_amount)
print("释放治疗:", skill.display_name)
注意,治疗技能也会消耗 mana 和进入冷却。
这已经在 cast_skill() 里处理了。
33. 技能释放状态
你可能希望技能释放时玩家不能移动。
可以加状态:
CASTING
PlayerState:
enum PlayerState {
IDLE,
WALK,
ATTACK,
CASTING,
DIALOGUE,
MENU,
HURT,
DEAD
}
cast_skill 成功时:
change_state(PlayerState.CASTING)
然后技能动画结束后回到 IDLE。
但第一版为了简单,可以先不加状态。
技能瞬发,玩家还能移动,也可以。
建议:
近战技能:短暂停一下
远程技能:可以移动释放
治疗技能:可以移动释放
第一版先简单:
技能立即执行
不额外加 CASTING
等你觉得手感怪,再加。
34. 技能冷却 UI
PlayerHUD 或 SkillBar 里显示技能冷却。
建议单独做:
SkillBar Control
├── SkillSlot1
├── SkillSlot2
├── SkillSlot3
└── SkillSlot4
每个 SkillSlot:
SkillSlot Control
├── Icon TextureRect
├── CooldownOverlay ColorRect
└── CooldownLabel Label
第一版可以先用 Label:
1 横扫斩 CD: 0.8
2 火球术 CD: 1.4
3 治疗术 CD: Ready
35. SkillBar.gd 简单版
extends Control
@export var player: Node
@onready var slot_1_label: Label = $Slot1Label
@onready var slot_2_label: Label = $Slot2Label
@onready var slot_3_label: Label = $Slot3Label
@onready var slot_4_label: Label = $Slot4Label
func _process(delta):
if player == null:
return
update_slot(slot_1_label, "1", player.skill_1)
update_slot(slot_2_label, "2", player.skill_2)
update_slot(slot_3_label, "3", player.skill_3)
update_slot(slot_4_label, "4", player.skill_4)
func update_slot(label: Label, key_name: String, skill: SkillData):
if skill == null:
label.text = key_name + ": 空"
return
var cd = player.get_skill_cooldown_remaining(skill.id)
if cd <= 0:
label.text = "%s: %s Ready" % [key_name, skill.display_name]
else:
label.text = "%s: %s %.1f" % [key_name, skill.display_name, cd]
这个很丑,但非常适合调试。
美化后面再做。
36. Mana / Stamina UI
PlayerHUD 添加:
ManaBar ProgressBar
ManaLabel Label
StaminaBar ProgressBar
StaminaLabel Label
PlayerHUD.gd:
@onready var mana_bar: ProgressBar = $ManaBar
@onready var mana_label: Label = $ManaLabel
@onready var stamina_bar: ProgressBar = $StaminaBar
@onready var stamina_label: Label = $StaminaLabel
setup 里:
if player.has_signal("mana_changed"):
player.mana_changed.connect(_on_mana_changed)
if player.has_signal("stamina_changed"):
player.stamina_changed.connect(_on_stamina_changed)
if "current_mana" in player and "max_mana" in player:
_on_mana_changed(player.current_mana, player.max_mana)
if "current_stamina" in player and "max_stamina" in player:
_on_stamina_changed(player.current_stamina, player.max_stamina)
方法:
func _on_mana_changed(current_mana: int, max_mana: int):
mana_bar.max_value = max_mana
mana_bar.value = current_mana
mana_label.text = "%d / %d" % [current_mana, max_mana]
func _on_stamina_changed(current_stamina: int, max_stamina: int):
stamina_bar.max_value = max_stamina
stamina_bar.value = current_stamina
stamina_label.text = "%d / %d" % [current_stamina, max_stamina]
37. Player 技能完整核心片段
signal mana_changed(current_mana: int, max_mana: int)
signal stamina_changed(current_stamina: int, max_stamina: int)
@export var max_mana: int = 50
@export var max_stamina: int = 100
@export var stamina_regen_per_second: float = 15.0
@export var skill_1: SkillData
@export var skill_2: SkillData
@export var skill_3: SkillData
@export var skill_4: SkillData
@export var skill_hitbox_scene: PackedScene
var current_mana: int = 50
var current_stamina: int = 100
var stamina_regen_accumulator: float = 0.0
var skill_cooldowns: Dictionary = {}
func initialize_resources():
current_mana = max_mana
current_stamina = max_stamina
mana_changed.emit(current_mana, max_mana)
stamina_changed.emit(current_stamina, max_stamina)
func _process(delta):
regenerate_stamina(delta)
update_skill_cooldowns(delta)
func regenerate_stamina(delta):
if current_stamina >= max_stamina:
return
stamina_regen_accumulator += stamina_regen_per_second * delta
if stamina_regen_accumulator >= 1.0:
var amount := int(stamina_regen_accumulator)
stamina_regen_accumulator -= amount
current_stamina = min(current_stamina + amount, max_stamina)
stamina_changed.emit(current_stamina, max_stamina)
func update_skill_cooldowns(delta):
for skill_id in skill_cooldowns.keys():
var remaining: float = skill_cooldowns[skill_id]
remaining -= delta
skill_cooldowns[skill_id] = max(remaining, 0.0)
func get_skill_cooldown_remaining(skill_id: String) -> float:
return skill_cooldowns.get(skill_id, 0.0)
func is_skill_ready(skill: SkillData) -> bool:
if skill == null:
return false
return get_skill_cooldown_remaining(skill.id) <= 0.0
func start_skill_cooldown(skill: SkillData):
skill_cooldowns[skill.id] = skill.cooldown
func has_enough_resource(skill: SkillData) -> bool:
if skill == null:
return false
match skill.cost_type:
SkillData.CostType.NONE:
return true
SkillData.CostType.MANA:
return current_mana >= skill.cost_amount
SkillData.CostType.STAMINA:
return current_stamina >= skill.cost_amount
SkillData.CostType.HP:
return current_hp > skill.cost_amount
return false
func spend_skill_cost(skill: SkillData) -> bool:
if not has_enough_resource(skill):
return false
match skill.cost_type:
SkillData.CostType.NONE:
pass
SkillData.CostType.MANA:
current_mana -= skill.cost_amount
mana_changed.emit(current_mana, max_mana)
SkillData.CostType.STAMINA:
current_stamina -= skill.cost_amount
stamina_changed.emit(current_stamina, max_stamina)
SkillData.CostType.HP:
current_hp -= skill.cost_amount
health_changed.emit(current_hp, max_hp)
return true
func cast_skill(skill: SkillData) -> bool:
if skill == null:
return false
if current_state == PlayerState.DEAD:
return false
if current_state == PlayerState.HURT:
return false
if not is_skill_ready(skill):
print("技能冷却中:", skill.display_name)
return false
if not has_enough_resource(skill):
print("资源不足:", skill.display_name)
return false
if not spend_skill_cost(skill):
return false
start_skill_cooldown(skill)
execute_skill(skill)
return true
func execute_skill(skill: SkillData):
if skill.animation_name != "":
play_skill_animation(skill.animation_name)
match skill.skill_type:
SkillData.SkillType.MELEE:
execute_melee_skill(skill)
SkillData.SkillType.PROJECTILE:
execute_projectile_skill(skill)
SkillData.SkillType.AOE:
execute_aoe_skill(skill)
SkillData.SkillType.HEAL:
execute_heal_skill(skill)
func execute_melee_skill(skill: SkillData):
if skill_hitbox_scene == null:
print("没有设置 skill_hitbox_scene")
return
var hitbox = skill_hitbox_scene.instantiate()
get_tree().current_scene.add_child(hitbox)
hitbox.global_position = get_melee_skill_position(skill)
if hitbox.has_method("setup"):
hitbox.setup(skill.damage + final_attack, self)
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_tree().current_scene.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)
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_tree().current_scene.add_child(aoe)
aoe.global_position = global_position
if aoe.has_method("setup"):
aoe.setup(skill.damage + final_attack, self, skill.radius)
func execute_heal_skill(skill: SkillData):
if skill.heal_amount <= 0:
return
heal(skill.heal_amount)
func get_melee_skill_position(skill: SkillData) -> Vector2:
var direction = last_direction.normalized()
if direction == Vector2.ZERO:
direction = Vector2.DOWN
return global_position + direction * skill.range
func play_skill_animation(anim_name: String):
if animated_sprite == null:
return
if animated_sprite.sprite_frames == null:
return
if animated_sprite.sprite_frames.has_animation(anim_name):
animated_sprite.play(anim_name)
38. 技能和普通攻击的关系
普通攻击其实也可以看成技能。
比如:
普通攻击 = 一个没有消耗、冷却很短的近战技能
以后你可以把普通攻击也做成 SkillData:
basic_attack.tres
skill_type = MELEE
damage = 10
cost_type = NONE
cooldown = 0.4
这样战斗逻辑会更统一。
但第一版不用急。
可以先保留普通攻击,再加技能系统。
39. 技能伤害公式怎么设计
现在我们用:
skill.damage + final_attack
这很简单。
以后可以改成:
技能伤害 = final_attack * 倍率 + 技能基础伤害
SkillData 加:
@export var attack_multiplier: float = 1.0
伤害:
var final_damage = int(skill.damage + final_attack * skill.attack_multiplier)
比如:
横扫斩:
damage = 5
attack_multiplier = 1.5
火球术:
damage = 20
attack_multiplier = 0.8
第一版先用加法。
等技能变多后,再改倍率。
40. 技能是否应该打断普通攻击
第一版可以不处理。
后面可以根据手感设计:
技能可以打断普通攻击
技能不能打断受伤
翻滚可以打断攻击
治疗不能移动
火球需要短暂施法
这会进入更复杂的状态机。
先让技能能释放、能命中、能冷却。
再谈手感。
41. 技能释放目标:方向、鼠标、锁定
第一版技能方向用:
last_direction
也就是玩家最后移动方向。
以后可以扩展:
鼠标方向
手柄右摇杆方向
锁定目标方向
自动瞄准最近敌人
比如鼠标方向:
var mouse_pos = get_global_mouse_position()
var direction = global_position.direction_to(mouse_pos)
Projectile 就可以朝鼠标发射。
如果你想做类星露谷、类塞尔达,方向键方向也够用。
42. 技能冷却和存档
技能冷却一般不需要存档。
玩家退出游戏再回来,技能冷却重置通常可以接受。
如果你想严谨,可以保存:
skill_cooldowns
但第一版不用。
mana、stamina 是否保存?
建议保存:
current_mana
current_stamina
但也可以读档恢复满。
第一版建议存一下,因为和 HP 类似。
SaveManager 的 player 数据里加:
"current_mana": player.current_mana,
"current_stamina": player.current_stamina,
"max_mana": player.max_mana,
"max_stamina": player.max_stamina
读档恢复后 emit:
player.mana_changed.emit(player.current_mana, player.max_mana)
player.stamina_changed.emit(player.current_stamina, player.max_stamina)
43. Mana 药水
之前物品系统有:
heal_amount
现在可以扩展:
@export var mana_restore_amount: int = 0
@export var stamina_restore_amount: int = 0
ItemData.gd:
@export var mana_restore_amount: int = 0
@export var stamina_restore_amount: int = 0
Player.use_item:
if item.mana_restore_amount > 0:
restore_mana(item.mana_restore_amount)
inventory.remove_item(item_id, 1)
return true
Player:
func restore_mana(amount: int):
current_mana = min(current_mana + amount, max_mana)
mana_changed.emit(current_mana, max_mana)
func restore_stamina(amount: int):
current_stamina = min(current_stamina + amount, max_stamina)
stamina_changed.emit(current_stamina, max_stamina)
44. 怪物技能
现在技能系统先给 Player 用。
但同一套 SkillData 也可以给怪物用。
比如法师怪:
fireball.tres
Enemy 也可以有:
@export var skills: Array[SkillData]
然后 AI 判断距离后释放技能。
但怪物技能需要:
AI 选择技能
施法冷却
目标方向
避开打到自己
这是后面的敌人战斗 AI 章节。
现在先做玩家技能。
45. 技能特效
第一版可以用:
Projectile Sprite
AOE AnimatedSprite2D
SkillHitbox 不显示
后面可以加:
释放特效
命中特效
爆炸特效
屏幕震动
音效
伤害数字
技能手感很大一部分来自反馈。
但反馈是锦上添花,先把命中逻辑做稳。
46. 常见错误 1:按技能键没反应
排查:
InputMap 是否添加 skill_1
Player.handle_normal_input 是否处理 skill_1
skill_1 是否拖了 SkillData
cast_skill 是否被调用
current_state 是否允许释放
临时打印:
print("尝试释放技能")
47. 常见错误 2:提示资源不足
排查:
skill.cost_type 是否是 MANA/STAMINA
skill.cost_amount 是否太高
Player.current_mana/current_stamina 是否初始化
UI 是否显示正确
如果 current_mana 默认是 0,说明初始化没做。
48. 常见错误 3:技能一直冷却中
排查:
update_skill_cooldowns 是否在 _process 里调用
skill_cooldowns[skill.id] 是否减到 0
skill.id 是否为空
cooldown 是否设置太大
特别注意:
SkillData.id 不能为空
如果多个技能 id 都是空字符串,它们会共用同一个冷却键,直接喜剧连台。
49. 常见错误 4:近战技能打不到怪
排查:
skill_hitbox_scene 是否设置
SkillHitbox 是否有 Area2D
CollisionShape2D 是否有 shape
hitbox 生成位置是否正确
Enemy Hurtbox 是否加入 hurtbox group
collision layer/mask 是否匹配
可以临时让 SkillHitbox 显示 Sprite 或 ColorRect,看看它到底生成在哪里。
50. 常见错误 5:火球飞了但没命中
排查:
Projectile 是 Area2D 吗
Projectile 有 CollisionShape2D 吗
Projectile monitoring 是否开启
Enemy Hurtbox monitorable 是否开启
area_entered 是否连接
collision mask 是否匹配
Hurtbox 是否有 take_damage
临时打印:
func _on_area_entered(area):
print("火球碰到:", area.name)
51. 常见错误 6:AOE 生成时敌人在范围内但没受伤
原因:
enemy 一开始就已经在 AOE 内
area_entered 未必触发
解决:
await get_tree().process_frame
for area in get_overlapping_areas():
hit_area(area)
这个在 AOE 技能里很重要。
52. 常见错误 7:治疗术扣了蓝但没回血
排查:
skill_type 是否是 HEAL
heal_amount 是否大于 0
execute_heal_skill 是否被调用
Player.heal 是否正常
health_changed 是否 emit
53. 常见错误 8:体力不恢复
排查:
regenerate_stamina 是否在 _process 调用
current_stamina 是否小于 max_stamina
stamina_regen_per_second 是否大于 0
stamina_changed 是否 emit
54. 常见错误 9:技能生成在错误场景
如果你用:
get_tree().current_scene.add_child(projectile)
在 Main 架构下,current_scene 是 Main。
这通常没问题。
但如果你希望技能属于当前地图,可以放到:
SceneManager.current_map.add_child(projectile)
比如:
var parent = SceneManager.current_map
if parent == null:
parent = get_tree().current_scene
parent.add_child(projectile)
这样切地图时,地图内技能会随地图清理。
第一版建议生成到当前地图:
func get_skill_parent() -> Node:
if SceneManager.current_map != null:
return SceneManager.current_map
return get_tree().current_scene
然后:
get_skill_parent().add_child(projectile)
55. 这一部分最重要的记忆点
技能系统核心是 SkillData + cast_skill + cooldown + cost
SkillData 用 Resource 很适合
Player 需要 mana / stamina
技能释放前检查冷却和资源
释放成功后消耗资源并进入冷却
近战技能可以生成 SkillHitbox
远程技能可以生成 Projectile
AOE 技能可以生成 AOEAttack
AOE 要主动检查 get_overlapping_areas
SkillData.id 绝对不能为空
技能 UI 第一版先用文字显示冷却即可
56. 你现在的小练习
按这个顺序做:
1. 创建 SkillData.gd
2. 创建 slash.tres
3. 创建 fireball.tres
4. 创建 heal.tres
5. 创建 ice_nova.tres
6. Player 添加 mana / stamina
7. Player 添加 mana_changed / stamina_changed
8. Player 添加 skill_1 / skill_2 / skill_3 / skill_4
9. InputMap 添加 skill_1 到 skill_4
10. Player 写 cast_skill()
11. Player 写 has_enough_resource()
12. Player 写 spend_skill_cost()
13. Player 写 cooldown 相关函数
14. 创建 SkillHitbox.tscn
15. 横扫斩生成 SkillHitbox
16. 创建 FireballProjectile.tscn
17. 火球术生成 Projectile
18. 创建 AOEAttack.tscn
19. 冰霜新星生成 AOE
20. 治疗术调用 heal()
21. PlayerHUD 添加 ManaBar / StaminaBar
22. 创建 SkillBar 显示 1/2/3/4 技能冷却
23. 测试横扫斩能打怪
24. 测试火球能飞行并命中
25. 测试冰霜新星能打周围敌人
26. 测试治疗术能回血
27. 测试技能冷却期间不能重复释放
28. 测试 mana/stamina 不足时不能释放
完成这一部分之后,你的战斗系统就会从“普通攻击”升级成“技能战斗”。
如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录