第九部分:攻击系统基础:AttackArea、Hitbox、Hurtbox、怪物受伤
这一部分开始进入“战斗系统”。
先别想复杂的连招、技能、卡牌、暴击、装备、属性克制。
我们先做最小闭环:
玩家按 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()
完成这个,你的游戏就从“能走、能对话”,进入“能战斗”的阶段了。
如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录