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

2026-05-19
4371 字约 15 分钟
...

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

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

我们先做最小闭环:

玩家按 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

AttackArea 的 CollisionShape2D 可以先用矩形。

比如:

宽 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 的 CollisionObject2D 有 collision_layer 和 collision_mask 两个属性,也有按层编号设置 layer / mask 的方法。(Godot Engine documentation)

现在建议这样规划:

Layer 1:world
Layer 2:player_body
Layer 3:enemy_body
Layer 4:interactable
Layer 5:player_hitbox
Layer 6:player_hurtbox
Layer 7:enemy_hitbox
Layer 8:enemy_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)

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

但为了后面扩展 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 的 AnimatedSprite2D 有 animation_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_down:Loop 关闭
attack_up:Loop 关闭
attack_left:Loop 关闭
attack_right:Loop 关闭

而这些可以循环:

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

评论

请登录后发表评论

去登录
加载评论中...