第二十五部分:战斗技能系统基础:技能数据、冷却、蓝量/体力、近战技能、远程技能、AOE 技能

2026-05-19
381413 分钟
...

这一部分开始升级战斗系统。

前面我们的战斗已经有:

玩家普通攻击
怪物受伤
怪物追击
怪物攻击玩家
血量
装备
等级
经验

但现在玩家的战斗方式还比较单一:

按攻击键
挥一下武器
造成伤害

现在我们要做技能系统,比如:

横扫斩
火球术
治疗术
翻滚
冰霜新星
范围爆炸
冲刺斩

技能系统会让战斗从“平砍”变成“有选择、有节奏、有资源管理”。

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

第一版先统一 slashcast

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 不足时不能释放

完成这一部分之后,你的战斗系统就会从“普通攻击”升级成“技能战斗”。

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

分享文章

相关文章

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

评论

请登录后发表评论

去登录
加载评论中...