第九部分:攻击系统基础:AttackArea、Hitbox、Hurtbox、怪物受伤

2026-05-19
437115 分钟
...

这一部分开始进入“战斗系统”。

先别想复杂的连招、技能、卡牌、暴击、装备、属性克制。

我们先做最小闭环:

玩家按 J 攻击

进入 ATTACK 状态

播放攻击动画

打开攻击判定 AttackArea

如果攻击范围碰到怪物 Hurtbox

怪物扣血

怪物血量为 0 时死亡

攻击动画结束

玩家回到 IDLE

这就是最基础的战斗系统。

1. 攻击系统由哪些部分组成

一个最小攻击系统通常有这些东西:

Player
AttackArea
Enemy
Hurtbox
Health
Damage
AttackState

对应职责:

Player
接收攻击输入进入 ATTACK 状态

AttackArea
玩家攻击范围

Enemy
怪物主体

Hurtbox
怪物受击范围

Health
生命值

Damage
伤害数值

AttackState
控制攻击期间不能移动不能重复攻击

如果你把这些都揉在一起,会很乱。

所以我们拆开理解。

2. AttackArea 是什么

AttackArea 通常是一个 Area2D

它代表玩家攻击时的检测范围。

结构:

Player CharacterBody2D
├── AnimatedSprite2D
├── CollisionShape2D
├── Camera2D
├── InteractArea Area2D
└── AttackArea Area2D
    └── CollisionShape2D

Area2D 本身很适合做检测区域,它可以检测对象重叠,并在对象进入或离开时发出信号;Godot 文档也说明 Area2D 可以用于检测 overlap、发出 body enter / exit 等信号。(Godot Engine documentation)

所以:

InteractArea检测能不能交互
AttackArea检测攻击打到了谁

这俩都是 Area2D,但语义不同。

3. Hitbox 和 Hurtbox 是什么

这两个词你以后会经常看到。

Hitbox
攻击判定盒
谁负责打别人

Hurtbox
受击判定盒
谁负责被别人打

比如玩家攻击:

Player AttackArea = Hitbox
Enemy Hurtbox = Hurtbox

怪物攻击玩家:

Enemy AttackArea = Hitbox
Player Hurtbox = Hurtbox

简单记:

Hitbox我打出去的拳头
Hurtbox我身上能被打到的范围

不要把 Hitbox 和 Hurtbox 混成一个东西。

短期也可以直接让 AttackArea 检测 Enemy 本体,但后面会不舒服。

更推荐早一点建立这个习惯:

攻击范围检测 Hurtbox
Hurtbox 再通知自己的 owner 扣血

4. 为什么不用 CharacterBody2D 的碰撞体当受击范围

玩家或怪物的 CollisionShape2D 主要用于“物理碰撞”。

比如:

不能穿墙
不能穿过障碍
角色之间是否互相挡住

但受击范围是另一件事。

比如一个怪物:

身体碰撞范围脚底附近用于挡路
受击范围整只怪物身体用于被打

如果你只用一个碰撞体,会遇到问题:

脚底碰撞太小攻击明明打到身体却没命中
整身体碰撞太大怪物走路又很卡

所以通常拆成:

CollisionShape2D移动和环境碰撞
Hurtbox Area2D受击检测
AttackArea Area2D攻击检测

Godot 的碰撞对象都继承自 CollisionObject2D,可以持有碰撞形状;而具体检测或响应应该按用途选择不同节点和形状。(Godot Engine documentation)

5. 玩家 AttackArea 的基础结构

建议先这样:

Player CharacterBody2D
├── AnimatedSprite2D
├── CollisionShape2D
├── Camera2D
├── InteractArea Area2D
│   └── CollisionShape2D
└── AttackArea Area2D
    └── CollisionShape2D

AttackAreaCollisionShape2D 可以先用矩形。

比如:

 24
 18

位置根据玩家朝向改变:

面朝下AttackArea 在玩家下方
面朝上AttackArea 在玩家上方
面朝左AttackArea 在玩家左方
面朝右AttackArea 在玩家右方

6. AttackArea 默认应该关闭

攻击范围不应该一直有效。

不然玩家站着不动时,只要怪物碰到 AttackArea 就会一直受伤。

所以默认:

AttackArea.monitoring = false

攻击开始时打开:

attack_area.monitoring = true

攻击结束时关闭:

attack_area.monitoring = false

你可以理解成:

攻击动画挥出去的那一小段时间Hitbox 才有效

一开始可以让整个攻击动画期间都有效。

后面更精细时,可以只在某几帧打开。

7. monitoring 和 monitorable 是什么感觉

monitoring 可以先理解成:

我是否主动检测别人

monitorable 可以理解成:

我是否允许被别人检测

对于攻击系统,通常可以这样:

Player AttackArea
monitoring = 攻击时 true平时 false
monitorable = false 或默认都行

Enemy Hurtbox
monitoring = false
monitorable = true

也就是说:

AttackArea 主动检测 Hurtbox
Hurtbox 负责被检测

第一阶段不用太纠结 monitorable,重点是:

AttackArea 平时别开 monitoring

8. Collision Layer 和 Mask 的推荐规划

前面讲过:

Layer我是谁
Mask我看谁

Godot 的 CollisionObject2Dcollision_layercollision_mask 两个属性,也有按层编号设置 layer / mask 的方法。(Godot Engine documentation)

现在建议这样规划:

Layer 1world
Layer 2player_body
Layer 3enemy_body
Layer 4interactable
Layer 5player_hitbox
Layer 6player_hurtbox
Layer 7enemy_hitbox
Layer 8enemy_hurtbox

第一阶段你真正需要:

world
player_body
enemy_body
player_hitbox
enemy_hurtbox

配置:

Player CharacterBody2D
Layer = player_body
Mask = world

Player AttackArea
Layer = player_hitbox
Mask = enemy_hurtbox

Enemy CharacterBody2D
Layer = enemy_body
Mask = world

Enemy Hurtbox
Layer = enemy_hurtbox
Mask =或者不重要

意思是:

玩家身体只负责撞世界
玩家攻击范围只检测怪物受击范围
怪物受击范围只是被检测

这样不会乱打到墙、NPC、宝箱。

9. Enemy 的基础结构

先做一个最简单怪物。

Slime CharacterBody2D
├── Sprite2D AnimatedSprite2D
├── CollisionShape2D
└── Hurtbox Area2D
    └── CollisionShape2D

怪物用 CharacterBody2D 是为了以后它能移动、追玩家、被击退。

CharacterBody2D 是 Godot 里适合用脚本控制移动的 2D 物理身体,通常用 move_and_slide() 这类方法处理移动和碰撞。(Godot Engine documentation)

如果你现在怪物不会动,也可以用 StaticBody2DArea2D 做临时测试。

但为了后面扩展 AI,我建议怪物一开始就用:

CharacterBody2D

10. Enemy.gd 基础版

先做血量和受伤。

extends CharacterBody2D

@export var max_hp: int = 30

@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D

var current_hp: int

func _ready():
    current_hp = max_hp

func take_damage(amount: int):
    current_hp -= amount
    print(name, " 受伤:", amount, " 当前血量:", current_hp)

    if current_hp <= 0:
        die()

func die():
    print(name, " 死亡")
    queue_free()

如果你暂时没有 AnimatedSprite2D,用 Sprite2D 也行,把那行删掉。

11. Hurtbox.gd 基础版

给怪物的 Hurtbox Area2D 挂一个脚本:

extends Area2D

@export var owner_node: Node

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

然后在 Inspector 里,把 Slime 节点拖到 owner_node

不过这种拖引用略麻烦。

更简单的写法是用父节点:

extends Area2D

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

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

因为结构是:

Slime
└── Hurtbox

所以 get_parent() 就是 Slime。

推荐先用这个版本。

12. Player AttackArea 检测 Hurtbox

在 Player.gd 里拿到 AttackArea:

@onready var attack_area: Area2D = $AttackArea

_ready() 里连接信号:

func _ready():
    attack_area.area_entered.connect(_on_attack_area_entered)
    attack_area.monitoring = false

当攻击范围碰到怪物 Hurtbox:

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

    if area.is_in_group("hurtbox"):
        if area.has_method("take_damage"):
            area.take_damage(attack_damage)

你需要给怪物的 Hurtbox 加入 group:

hurtbox

13. Player 加攻击伤害

在 Player.gd 里加:

@export var attack_damage: int = 10

这样可以在 Inspector 里调整。

完整相关部分:

@export var attack_damage: int = 10

@onready var attack_area: Area2D = $AttackArea

func _ready():
    attack_area.area_entered.connect(_on_attack_area_entered)
    attack_area.monitoring = false

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

    if area.is_in_group("hurtbox") and area.has_method("take_damage"):
        area.take_damage(attack_damage)

这就是最基础的伤害检测。

14. 攻击开始时打开 AttackArea

在状态机的 enter_state() 里:

func enter_state(state: PlayerState):
    match state:
        PlayerState.ATTACK:
            velocity = Vector2.ZERO
            update_attack_area_position()
            attack_area.monitoring = true
            animated_sprite.play("attack_" + get_direction_name(last_direction))

攻击结束时关闭:

func exit_state(state: PlayerState):
    match state:
        PlayerState.ATTACK:
            attack_area.monitoring = false

这样:

只有攻击状态时 AttackArea 有效

15. 攻击动画结束后回到 Idle

如果用 AnimatedSprite2D,可以连接:

animated_sprite.animation_finished.connect(_on_animation_finished)

Godot 的 AnimatedSprite2Danimation_finished() 信号,动画到达结尾时会发出;需要注意的是,循环动画不会发出这个结束信号。(Godot Engine documentation)

代码:

func _on_animation_finished():
    if current_state == PlayerState.ATTACK:
        change_state(PlayerState.IDLE)
        return

注意:

attack_down / attack_up / attack_left / attack_right
不要设置为循环

否则动画结束信号不会触发。

16. 攻击范围根据朝向移动

如果玩家面朝下,攻击范围应该在下方。

func update_attack_area_position():
    var offset := Vector2.ZERO

    if abs(last_direction.x) > abs(last_direction.y):
        offset.x = 24 if last_direction.x > 0 else -24
    else:
        offset.y = 24 if last_direction.y > 0 else -24

    attack_area.position = offset

如果你的角色是 32x48,tile 是 32x32,可以先试:

offset = 24

如果攻击打不到,就调大一点。

如果隔太远也打到,就调小一点。

17. 攻击范围形状也应该根据方向改变

如果 AttackArea 是矩形:

左右攻击宽一点高一点窄
上下攻击高一点宽一点窄

你可以先不做,直接用一个正方形/圆形范围。

第一版建议:

AttackArea RectangleShape2D
大小 28 x 22

这样四方向都能勉强用。

后面你再细化:

向左/向右攻击框横着
向上/向下攻击框竖着

如果想通过代码改矩形大小:

@onready var attack_shape: CollisionShape2D = $AttackArea/CollisionShape2D

func update_attack_area_position():
    var shape := attack_shape.shape as RectangleShape2D

    if abs(last_direction.x) > abs(last_direction.y):
        attack_area.position = Vector2(24 if last_direction.x > 0 else -24, 0)
        shape.size = Vector2(28, 20)
    else:
        attack_area.position = Vector2(0, 24 if last_direction.y > 0 else -24)
        shape.size = Vector2(24, 28)

注意不要直接缩放 CollisionShape2D 节点。

Godot 文档特别提醒,调整碰撞形状大小时应使用形状本身的尺寸控制,不建议通过缩放碰撞形状节点来改变碰撞大小,因为这可能带来意外碰撞行为。(Godot Engine documentation)

18. 防止一次攻击重复打同一个怪物

现在有一个问题。

如果 AttackArea 开着时,Hurtbox 进入了,触发一次。

但在复杂情况下,可能一次攻击多次命中同一个目标。

所以需要记录:

var hit_targets: Array[Node] = []

攻击开始时清空:

func enter_state(state: PlayerState):
    match state:
        PlayerState.ATTACK:
            hit_targets.clear()
            velocity = Vector2.ZERO
            update_attack_area_position()
            attack_area.monitoring = true
            animated_sprite.play("attack_" + get_direction_name(last_direction))

命中时判断:

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

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

    if hit_targets.has(area):
        return

    hit_targets.append(area)

    if area.has_method("take_damage"):
        area.take_damage(attack_damage)

这样:

一次攻击中同一个 Hurtbox 只会被打一次

19. 为什么记录 Hurtbox,而不是 Enemy

第一阶段记录 area 就够。

因为我们检测到的是 Hurtbox:

hit_targets.append(area)

如果一个怪物有多个 Hurtbox,比如头、身体、尾巴,那你可能要记录怪物本体,避免一剑同时打中多个部位造成多次伤害。

那时可以让 Hurtbox 提供:

func get_owner_node() -> Node:
    return get_parent()

然后记录 owner。

但现在不用复杂化。

先记住:

简单怪物一个 Hurtbox记录 area 就够
复杂怪物多个 Hurtbox记录 owner 更好

20. 怪物受伤时闪烁

先做一个简单反馈。

Enemy.gd:

func take_damage(amount: int):
    current_hp -= amount
    flash()

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

    if current_hp <= 0:
        die()

func flash():
    if animated_sprite == null:
        return

    animated_sprite.modulate = Color(1, 0.4, 0.4)
    await get_tree().create_timer(0.1).timeout
    animated_sprite.modulate = Color(1, 1, 1)

如果你用的是 Sprite2D

@onready var sprite: Sprite2D = $Sprite2D

animated_sprite 换成 sprite

这里的 modulate 可以理解成给精灵整体叠一个颜色。

受伤时闪红一下,玩家立刻知道打中了。

21. 怪物死亡不要马上复杂化

最简单:

func die():
    queue_free()

之后可以扩展:

播放死亡动画
掉落金币
掉落道具
生成经验值
通知任务系统
播放音效
生成粒子特效

但第一版不要全加。

先让它:

血量归零
消失

能跑通就是好东西。

22. Enemy.gd 完整基础版

extends CharacterBody2D

@export var max_hp: int = 30

@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D

var current_hp: int

func _ready():
    current_hp = max_hp

func take_damage(amount: int):
    if current_hp <= 0:
        return

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

    flash()

    if current_hp <= 0:
        die()

func flash():
    if animated_sprite == null:
        return

    animated_sprite.modulate = Color(1, 0.4, 0.4)
    await get_tree().create_timer(0.1).timeout

    if is_instance_valid(animated_sprite):
        animated_sprite.modulate = Color(1, 1, 1)

func die():
    print(name, " 死亡")
    queue_free()

如果你的怪物没有 AnimatedSprite2D,就先改成 Sprite2D。

23. Hurtbox.gd 完整基础版

extends Area2D

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

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

给 Hurtbox 加 group:

hurtbox

Layer 设置:

Layer = enemy_hurtbox
Mask =

24. Player.gd 攻击相关完整片段

下面只写攻击相关部分,不重复整个 Player.gd。

@export var attack_damage: int = 10

@onready var attack_area: Area2D = $AttackArea
@onready var attack_shape: CollisionShape2D = $AttackArea/CollisionShape2D

var hit_targets: Array[Node] = []

func _ready():
    attack_area.area_entered.connect(_on_attack_area_entered)
    attack_area.monitoring = false
    animated_sprite.animation_finished.connect(_on_animation_finished)

func handle_normal_input(event):
    if event.is_action_pressed("attack"):
        change_state(PlayerState.ATTACK)
        return

    if event.is_action_pressed("interact"):
        try_interact()
        return

func enter_state(state: PlayerState):
    match state:
        PlayerState.ATTACK:
            hit_targets.clear()
            velocity = Vector2.ZERO
            update_attack_area()
            attack_area.monitoring = true
            animated_sprite.play("attack_" + get_direction_name(last_direction))

func exit_state(state: PlayerState):
    match state:
        PlayerState.ATTACK:
            attack_area.monitoring = false

func physics_attack(delta):
    velocity = Vector2.ZERO
    move_and_slide()

func update_attack_area():
    var shape := attack_shape.shape as RectangleShape2D

    if abs(last_direction.x) > abs(last_direction.y):
        attack_area.position = Vector2(24 if last_direction.x > 0 else -24, 0)

        if shape != null:
            shape.size = Vector2(28, 20)
    else:
        attack_area.position = Vector2(0, 24 if last_direction.y > 0 else -24)

        if shape != null:
            shape.size = Vector2(24, 28)

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

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

    if hit_targets.has(area):
        return

    hit_targets.append(area)

    if area.has_method("take_damage"):
        area.take_damage(attack_damage)

func _on_animation_finished():
    if current_state == PlayerState.ATTACK:
        change_state(PlayerState.IDLE)
        return

这已经可以实现:

按攻击键
播放攻击动画
打开攻击范围
检测怪物 Hurtbox
造成伤害
动画结束后关闭攻击范围并回到 Idle

25. InputMap 里要添加 attack

项目设置里加:

attack

绑定:

J
鼠标左键

第一阶段建议先用 J

鼠标左键后面可能涉及 UI 点击、方向瞄准,先不急。

代码里:

if event.is_action_pressed("attack"):
    change_state(PlayerState.ATTACK)

如果按 J 没反应,先检查 InputMap。

别怀疑人生,Godot 初学前十个 bug,五个都像 InputMap 忘配。

26. 攻击动画命名建议

如果你有四方向攻击动画:

attack_down
attack_up
attack_left
attack_right

对应已有的:

idle_down
idle_up
idle_left
idle_right

walk_down
walk_up
walk_left
walk_right

这样命名最清楚。

代码:

animated_sprite.play("attack_" + get_direction_name(last_direction))

就能自然拼出:

attack_down
attack_up
attack_left
attack_right

如果你暂时没有攻击动画,可以先临时播放 idle:

animated_sprite.play("idle_" + get_direction_name(last_direction))

但这样 animation_finished 可能不好用,因为 idle 往往是循环动画。

临时解决可以用 Timer:

func enter_state(state: PlayerState):
    match state:
        PlayerState.ATTACK:
            hit_targets.clear()
            velocity = Vector2.ZERO
            update_attack_area()
            attack_area.monitoring = true
            await get_tree().create_timer(0.25).timeout

            if current_state == PlayerState.ATTACK:
                change_state(PlayerState.IDLE)

等有攻击动画后再换成 animation_finished

27. 攻击动画一定不要循环

攻击动画如果循环,就会出现:

一直停在 ATTACK 状态
animation_finished 不触发
玩家不能回到 Idle

因为 AnimatedSprite2D.animation_finished() 不会在循环动画上触发结束信号。(Godot Engine documentation)

所以:

attack_downLoop 关闭
attack_upLoop 关闭
attack_leftLoop 关闭
attack_rightLoop 关闭

而这些可以循环:

idle_down
walk_down

尤其 walk 必须循环,不然走两步动画就停了。

28. 攻击期间禁止移动

这已经由状态机解决了:

func physics_attack(delta):
    velocity = Vector2.ZERO
    move_and_slide()

并且 _unhandled_input() 里:

PlayerState.ATTACK:
    pass

所以攻击期间不会响应普通输入。

这就是状态机的舒服之处。

不用写一堆:

if is_attacking:
    return

29. 攻击期间是否允许转向

第一阶段建议:

不允许

也就是说,玩家攻击时方向固定,等攻击结束后再响应移动和转向。

这样最简单、最好控。

后面如果你想做更灵活的手感,可以允许:

攻击前摇期间可以转向
攻击命中帧后不能转向
某些武器可以移动攻击
某些技能可以蓄力转向

这些都属于进阶战斗手感。

先别急,先做清楚。

30. 怪物能不能挡住玩家

现在建议怪物先不要挡住玩家。

也就是说:

Enemy Body 不一定要和 Player Body 碰撞

否则玩家靠近怪物时容易卡住,攻击也可能不舒服。

你可以先让:

Player Mask = world
Enemy Mask = world

玩家和怪物之间不互相物理阻挡。

攻击通过 Hurtbox 检测。

这样手感更宽松。

后面你想让怪物挡路,再让 Player mask 勾 enemy_body。

31. 为什么攻击检测用 Area2D,不用 move_and_slide 碰撞

因为攻击不是“物理移动碰撞”。

攻击是“范围命中检测”。

CharacterBody2D 主要处理脚本控制移动和碰撞响应,像玩家走路、撞墙这类最适合用它。(Godot Engine documentation)

但攻击这种东西更像:

在某一瞬间检查一个范围内有没有目标

所以用 Area2D 更自然。

32. 更精细的攻击:只在命中帧打开 AttackArea

现在我们是:

整个攻击状态期间 AttackArea 都开着

但更好的做法是:

攻击前摇AttackArea 关闭
武器挥出AttackArea 开启
挥完收招AttackArea 关闭

比如:

 0.00s开始攻击动画
 0.10s打开攻击判定
 0.20s关闭攻击判定
 0.35s攻击结束回到 Idle

以后可以用:

AnimationPlayer 调用方法
动画帧事件
Timer

第一阶段不用。

但你要知道,真正的战斗手感,很大一部分来自:

攻击判定打开的时机
攻击判定持续多久
攻击后摇能不能取消

这个比伤害数值还重要。

33. 击退 Knockback 的概念

怪物被打后通常会后退一点。

这叫击退。

最简单思路:

玩家攻击方向

传给怪物

怪物按方向移动一小段

现在 Hurtbox 只接收:

take_damage(amount)

可以扩展成:

take_damage(amount, knockback_direction)

Player 命中时:

var knockback_direction = last_direction.normalized()
area.take_damage(attack_damage, knockback_direction)

Hurtbox:

func take_damage(amount: int, knockback_direction: Vector2 = Vector2.ZERO):
    var owner_node = get_parent()

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

Enemy:

func take_damage(amount: int, knockback_direction: Vector2 = Vector2.ZERO):
    current_hp -= amount
    velocity = knockback_direction * 120.0

    if current_hp <= 0:
        die()

不过击退要和 Enemy 状态机配合,不然下一帧 AI 又把 velocity 覆盖了。

所以这属于下一阶段。

现在先知道它会这么扩展。

34. 伤害数字 DamageNumber

打中怪物时,如果只闪红,反馈还不够。

以后可以生成伤害数字:

-10

飘一下然后消失。

这通常是一个独立场景:

DamageNumber Node2D
└── Label

命中时实例化:

var damage_number = damage_number_scene.instantiate()
get_tree().current_scene.add_child(damage_number)
damage_number.global_position = area.global_position
damage_number.set_value(attack_damage)

这个后面可以单独讲。

第一阶段先不做。

35. 音效和特效也不要急

攻击系统后面会有:

挥剑音效
命中音效
怪物受伤叫声
命中特效
屏幕轻微震动
伤害数字
击退
暂停几帧 hit stop

但现在先做最小闭环:

 J
怪物扣血
怪物死亡

只要这个跑通,后面所有反馈都是往上加调味料。

没有闭环就加特效,就像锅里没饭先撒葱花,看着忙,吃不饱。

36. 当前推荐的完整节点结构

现在项目结构可以变成:

Main Node
├── World Node2D
│   ├── Ground TileMapLayer
│   ├── Obstacles TileMapLayer
│   ├── Interactables Node2D
│   │   ├── NPC Area2D
│   │   ├── Chest Area2D
│   │   └── Door Area2D
│   ├── Enemies Node2D
│   │   └── Slime CharacterBody2D
│   │       ├── AnimatedSprite2D
│   │       ├── CollisionShape2D
│   │       └── Hurtbox Area2D
│   │           └── CollisionShape2D
│   ├── Player CharacterBody2D
│   │   ├── AnimatedSprite2D
│   │   ├── CollisionShape2D
│   │   ├── Camera2D
│   │   ├── InteractArea Area2D
│   │   │   └── CollisionShape2D
│   │   └── AttackArea Area2D
│   │       └── CollisionShape2D
│   └── Foreground TileMapLayer
└── UI CanvasLayer
    ├── InteractLabel Label
    └── DialogueBox Control

37. 常见错误 1:攻击打不到怪物

按这个顺序排查:

AttackArea 是否有 CollisionShape2D
AttackArea Shape 是否设置了
AttackArea.monitoring 攻击时是否为 true
Hurtbox 是否是 Area2D
Hurtbox 是否有 CollisionShape2D
Hurtbox 是否加入 hurtbox
AttackArea Mask 是否勾 enemy_hurtbox
Hurtbox Layer 是否是 enemy_hurtbox
area_entered 信号有没有连接
攻击范围位置是否真的在怪物身上

调试时可以临时:

func _on_attack_area_entered(area):
    print("攻击范围检测到:", area.name)

如果不打印,说明碰撞检测没触发。

如果打印了但不扣血,说明 group 或 take_damage 方法有问题。

38. 常见错误 2:攻击动画结束后卡住

检查:

attack 动画是否关闭 Loop
animated_sprite.animation_finished 是否连接
_on_animation_finished 是否判断 ATTACK
change_state 是否允许 ATTACK -> IDLE

如果你暂时没有非循环攻击动画,用 Timer 方案先顶着。

39. 常见错误 3:攻击范围一直有效

检查:

_ready() 有没有 attack_area.monitoring = false
enter ATTACK 有没有打开 monitoring
exit ATTACK 有没有关闭 monitoring
change_state 是否真的走 exit_state

如果你直接写:

current_state = PlayerState.IDLE

而不是:

change_state(PlayerState.IDLE)

exit_state() 不会执行。

所以状态切换要统一用 change_state()

40. 常见错误 4:怪物扣血多次

加:

var hit_targets: Array[Node] = []

攻击开始:

hit_targets.clear()

命中时:

if hit_targets.has(area):
    return

hit_targets.append(area)

一次攻击一个目标只扣一次。

41. 常见错误 5:攻击方向不对

检查:

last_direction 是否正确更新
update_attack_area() 是否使用 last_direction
上方向 y 应该是负数
下方向 y 应该是正数
左方向 x 是负数
右方向 x 是正数

Godot 2D 坐标里:

x
x
y
y

所以:

Vector2.DOWN = Vector2(0, 1)
Vector2.UP = Vector2(0, -1)

42. 常见错误 6:Hurtbox 找不到父节点 take_damage

如果 Hurtbox 脚本是:

var owner_node = get_parent()

那结构必须是:

Slime
└── Hurtbox

如果你结构是:

Slime
└── Areas
    └── Hurtbox

get_parent() 得到的是 Areas,不是 Slime

这时要改成:

var owner_node = get_parent().get_parent()

或者更稳:

@export var owner_node: Node

然后 Inspector 里手动拖 Slime。

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

AttackArea 是玩家攻击范围
Hitbox 是攻击判定
Hurtbox 是受击判定
攻击不要直接用角色身体碰撞体
玩家物理碰撞攻击检测受击检测最好分开
AttackArea 平时关闭攻击时打开
攻击结束时必须关闭 AttackArea
Hurtbox 加入 hurtbox
Player AttackArea Mask 检测 enemy_hurtbox
Enemy Hurtbox Layer 设置为 enemy_hurtbox
 hit_targets 防止一次攻击重复命中
攻击动画不要循环否则 animation_finished 不触发

44. 你现在的小练习

做一个最小战斗闭环:

1. InputMap 添加 attack绑定 J
2. Player 添加 AttackArea
3. AttackArea 添加 CollisionShape2D
4. AttackArea 默认 monitoring = false
5. Player J 切到 ATTACK 状态
6. 进入 ATTACK 时打开 AttackArea
7. 攻击动画结束时回到 IDLE
8. 退出 ATTACK 时关闭 AttackArea
9. 创建 Slime CharacterBody2D
10. Slime 添加 Hurtbox Area2D
11. Hurtbox 加入 hurtbox
12. AttackArea 检测 Hurtbox
13. 命中时调用 Hurtbox.take_damage()
14. Slime 扣血
15. Slime 血量为 0 queue_free()

完成这个,你的游戏就从“能走、能对话”,进入“能战斗”的阶段了。

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

分享文章

相关文章

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

评论

请登录后发表评论

去登录
加载评论中...