☰
Godot敌人生成系统:动画、翻转与碰撞的完整实现
2026/10/14 5:23:54 网站建设 项目流程

Godot游戏练习01-第15节-敌人生成动画,翻转,碰撞

做了十来年游戏开发,从最早的Flash时代一路做到Unity、Godot,说实话现在很多新手教程教敌人生成,上来就摆个场景拖个脚本,却从来不告诉你为什么敌人要分“生成动画”“翻转”“碰撞”三件事来对待。这三个点看起来是三个独立的小功能,实际上串起来才是一整套敌人生成系统的骨架。你把这节啃透了,后面什么Boss战、波次刷怪、追击AI,基本上都是在这个骨架上添砖加瓦。

这节的内容就是围绕一个核心场景展开:敌人从屏幕外或者某个出生点出现,播放一个生成动画(比如淡入、传送门、掉落),然后根据玩家位置自动翻转朝向,最后给敌人挂上碰撞体,让子弹打中它有反应、它碰到玩家会扣血。这个练习非常适合刚学完Godot基础节点和信号机制的开发者,它把GDScript里最常用的几类逻辑——动画播放、方向判断、碰撞检测——揉在一起做了个完整的小闭环。

我直接说结论:做完这一节,你会掌握AnimatedSprite2D或者AnimationPlayer的切换控制、Sprite2D的flip_h翻转技巧、Area2D与CollisionShape2D的碰撞配置,以及如何通过信号把“生成完成”这个事件通知到游戏主逻辑。下面我按我实际踩坑的顺序,把整套思路、配置和代码逐段拆开讲。

1. 整体设计与思路拆解:为什么敌人生成需要“三段式”处理

1.1 从需求反推:这三个动作分别解决什么问题

很多人在做敌人系统时习惯把逻辑全写在一个脚本里:敌人出来,播放一下动画,然后直接冲向玩家。这样写不是不行,但后面要加无敌帧、受击反馈、死亡消散、掉落物品的时候,你会发现代码全糊在一起,改一处崩三处。

所以我把“敌人生成”这个需求拆成三个互相独立又顺序衔接的环节:

  • 生成动画:解决“敌人怎么出现”的视觉表达。可以是简单的淡入、缩放,也可以是传送门闪烁、从地里钻出来。这段动画播放期间,敌人不应该参与战斗逻辑,否则会出现“还没出生就被打死”或者“出生瞬间卡碰撞”的诡异现象。
  • 翻转:解决“敌人朝哪个方向走/打”的表现问题。2D游戏里敌人不可能只有一个朝向,它至少要能面对玩家。Godot里最轻量的做法就是Sprite2D的flip_h属性,翻一下纹理,不用重新做一套朝向素材。
  • 碰撞:解决“敌人和世界怎么交互”的问题。这包括它自己有没有实体阻挡(用CharacterBody2D+CollisionShape2D),以及它是否需要一个感知范围来触发战斗逻辑(用Area2D)。没有碰撞,子弹打它没判定,它撞玩家也穿模,整个游戏就没有“反馈”可言。

这三步的顺序是固定的:生成动画先播,播完才允许碰撞参与逻辑;翻转基于玩家位置实时计算;碰撞体从一开始就可以挂上,但是否启用要跟生成状态绑定。这样设计的好处是,你随时可以把“生成动画”换成“掉落出场”或者“传送门出现”,完全不影响翻转和碰撞模块的运作。

1.2 方案选型:为什么选AnimatedSprite2D而不是AnimationPlayer

Godot里做动画有两种常见路径:一是用AnimatedSprite2D配合SpriteFrames资源,二是用AnimationPlayer配合关键帧轨道。对于敌人这种“一组固定动作循环播放”的场景,我建议首选AnimatedSprite2D。

原因很简单:敌人的动画通常是循环的(比如行走、奔跑),AnimatedSprite2D天然支持autoplay、animation切换、速度倍率调整,而且可以直接在Inspector里预览帧序列。AnimationPlayer更擅长做UI动效、镜头移动、身体部位的插值动画,用于单个敌人的常规动作反而绕。

当然,如果你的敌人有“从传送门出现+张牙舞爪+落地”这种复合动画,也没问题,你仍然可以在AnimatedSprite2D上叠加一个AnimationPlayer来做整体的位移/缩放控制,两者不冲突。只是核心的帧动画播放交给AnimatedSprite2D会更顺手。

1.3 节点结构规划:一张图理清敌人场景的层级关系

我习惯把敌人做成一个独立的场景,根节点用CharacterBody2D,下面挂这些子节点:

  • AnimatedSprite2D:负责播放行走、生成、死亡等动画。
  • CollisionShape2D:作为物理碰撞体,决定实体大小。
  • Area2D(可命名为Hitbox):挂在碰撞体旁边,用作受击判定,配合子弹的检测。
  • Area2D(可命名为SpawnDetector):用于判定“生成动画是否播完”的边界区域,或者在需要时用来检测玩家是否进入警戒范围。

根节点为什么要用CharacterBody2D而不是Node2D?因为绝大多数敌人最终都要移动、要跟地图碰撞,CharacterBody2D自带move_and_slide(),省得你自己写速度迭代。如果敌人完全不移动、只是静态障碍物,那用StaticBody2D也行,但本节的敌人显然是要动起来的。

层级干净的最大好处是:你不用在脚本里反复找节点路径,用$AnimatedSprite2D、$CollisionShape2D就能直接访问,代码可读性也高很多。

2. 核心细节解析与实操要点:动画、翻转、碰撞的逐个击破

2.1 生成动画:设计出生状态的完整闭环

生成动画的核心不只是“放一段动画”,而是要处理好“生成期间敌人处于什么状态”。

我先讲最简单的实现:在敌人的脚本里定义一个状态枚举。

enum State { SPAWNING, ACTIVE, DYING } var state: int = State.SPAWNING

在_ready()里,先设置碰撞体禁用,同时播放出生动画:

func _ready() -> void: $CollisionShape2D.set_deferred("disabled", true) $AnimatedSprite2D.play("spawn") $AnimatedSprite2D.animation_finished.connect(_on_spawn_animation_finished)

关键点来了:为什么碰撞体要用set_deferred而不是直接改disabled?因为物理引擎的碰撞状态在物理帧处理中才能安全修改,如果你在_ready里直接改,很可能出现一帧的竞态窗口,导致敌人“被提前碰撞到”或“碰撞体卡在某处”。set_deferred会把这个修改推迟到物理帧的安全时机,这是我在真实项目里踩过坑之后才养成的习惯。

然后处理动画播放完成的回调:

func _on_spawn_animation_finished() -> void: state = State.ACTIVE $CollisionShape2D.set_deferred("disabled", false) $AnimatedSprite2D.play("walk")

这里有一个细节:AnimatedSprite2D的animation_finished信号只在非循环动画播放完时触发。如果你的spawn动画在SpriteFrames里被设置成了Loop,那这个信号永远不会发,敌人就会永远卡在生成状态。所以生成动画、死亡动画这些一次性动作,一定要把Loop选项关掉。这是我见过新手最容易犯的错误,没有之一。

如果不想依赖animation_finished信号,也可以用一个计时器来做备选方案,比如生成动画1秒后强制进入ACTIVE状态。双保险在实际项目里很常见,因为美术给的动画帧率不一定是整数的,动画时长略有偏差时,用Timer兜底会更稳。

2.2 翻转:让敌人朝向玩家的实时判断逻辑

敌人的翻转通常有两种诉求:一是“朝向玩家”,二是“朝向移动方向”。这两种逻辑在大多数情况下是统一的,因为敌人的移动方向就是朝玩家的方向,所以我直接讲怎么把这两种合起来。

核心代码非常简单:

func _update_facing() -> void: var direction: float = global_position.distance_squared_to(player.global_position) # 这里用玩家位置的x坐标与敌人x坐标做差 var diff: float = player.global_position.x - global_position.x if abs(diff) > 0.01: $AnimatedSprite2D.flip_h = diff < 0

为什么用flip_h而不是rotation?因为2D横版游戏里敌人只需要左右翻转,用flip_h是最轻量的方案,不需要旋转插值,也不影响碰撞体方向。如果你做的是俯视角游戏,那可能用rotation更合适,但那是另一套逻辑了。

这里有个细节值得注意:如果你用了flip_h,你的碰撞体如果只在一边设置(比如攻击范围在敌人前方),翻转后碰撞体的位置不会跟着翻转,它还是固定在局部坐标的某个方向。想要碰撞体也跟着左右切换,常见做法是把受击区域做成子节点,在翻转时同步设置scale.x或者position.x。我个人的习惯是:攻击判定区域尽量做成对称的,如果实在需要不对称,就根据flip_h手动调整Area2D的position。

翻转频率不要每帧都做?这个其实无所谓。Godot每帧更新flip_h的开销极低,一个布尔赋值而已。但如果你有多个敌人同时翻转,建议只在状态为ACTIVE时才执行这个判断,生成期间别动朝向,否则会出现敌人“没出生就扭头看你”的鬼畜画面。

2.3 碰撞:Area2D与CharacterBody2D的分工合作

碰撞在本节里实际上有两层含义:物理阻挡和受击判定。CharacterBody2D自带一个CollisionShape2D,解决的是“敌人不能穿墙、不能互相穿过”的问题。Area2D解决的是“子弹打中它、它碰到玩家”的检测问题。

受击判定我建议用Area2D。原因:Area2D通过body_entered和area_entered信号做检测,比物理引擎的碰撞响应更灵活,不需要两个物体都是物理体才能交互。子弹用Area2D或普通Node2D+检测都行,只要子弹的area_entered信号能送到敌人身上就行。

一个相对标准的受击检测实现是:

@onready var hitbox: Area2D = $Hitbox func _ready() -> void: hitbox.area_entered.connect(_on_hitbox_area_entered) func _on_hitbox_area_entered(area: Area2D) -> void: if area.is_in_group("bullet"): take_damage(area.damage)

这里要提醒一句:如果你在敌人的碰撞体还没启用前就把Hitbox启用,那敌人就会在生成期间被打。所以Hitbox的检测状态也应该跟state绑定。

完整的写法可以是:

func _on_spawn_animation_finished() -> void: state = State.ACTIVE $CollisionShape2D.set_deferred("disabled", false) hitbox.monitoring = true $AnimatedSprite2D.play("walk")

monitoring属性是Area2D是否检测其他物体的总开关。生成期间把monitoring设为false,敌人就“隐身”了,任何子弹都打不中它。等生成结束再打开,非常干净。

碰撞体的形状选择也有讲究。敌人主体我一般用RectangleShape2D或者CapsuleShape2D,受击判定区域可以稍微比可视体小一圈,给玩家一个“我明明打中了边缘却没判定”的可接受误差范围。反过来,敌人碰到玩家的伤害判定区域可以稍微比可视体大一圈,这样玩家闪避的判定会宽松些,手感更好。

2.4 参数选择的经验值:碰撞层、速度与动画节奏

这节里最容易出问题的不是代码,而是Inspector面板里的那些参数。我把我常用的配置直接列出来,你们可以对照着调:

参数项推荐值/方案理由
物理层敌人单独占一层(如Layer 3),玩家占Layer 2方便做层间碰撞掩码,避免敌人在生成动画阶段误触发其他物理事件
CollisionShape2D形状视敌人尺寸用Rectangle或CapsuleCapsule在上下坡更顺滑,Rectangle更适合大体型
AnimatedSprite2D帧率5-8 FPS(生成动画),8-12 FPS(行走)帧率太低会卡顿明显,太高会丢失像素风应有的节奏感
初始速度60-120像素/秒(视游戏分辨率)太快玩家反应不过来,太慢敌人毫无威胁
翻转灵敏度阈值0.01像素避免玩家和敌人x坐标几乎相等时反复抖动

移动逻辑如果敌人是追着玩家跑,在_process里做位置更新即可:

const SPEED := 80.0 func _physics_process(delta: float) -> void: if state != State.ACTIVE: return var direction := (player.global_position - global_position).normalized() velocity = direction * SPEED move_and_slide()

这里把速度用const声明而不是var,是因为敌人速度通常固定,用const防止后面误改。如果要做不同敌人类型,建议用导出变量@export var speed: float = 80.0,这样可以在Inspector里按敌人配置。

3. 实操过程与核心环节实现:从零搭出一个带生成动画的敌人

3.1 创建敌人场景:节点挂载与资源准备

第一步,新建一个场景,根节点选CharacterBody2D,命名Enemy。保存为enemy.tscn。

第二步,给根节点挂脚本,然后添加四个子节点:

  • AnimatedSprite2D
  • CollisionShape2D
  • Area2D(重命名为Hitbox)
  • Area2D(重命名为SpawnDetector,可选)

AnimatedSprite2D需要准备SpriteFrames资源。你可以用Godot自带的图标做占位,也可以导入一张像素图后切分。在Inspector面板的SpriteFrames属性里,点击New SpriteFrames,然后打开SpriteFrames面板,添加两个动画:spawn和walk。spawn动画的Loop不要勾选,walk的Loop必须勾选。

我建议spawn动画随便做三到五帧,比如从半透明到完全显示,或者从缩小放大到正常。如果你美术素材还不到位,也可以用Tween来做进度动画,但那是另一种方案,我下面会单独讲。

3.2 写敌人脚本:状态机+翻转+移动整合

在Enemy根节点的脚本里写完整逻辑。我把GDScript贴出来,每个重点行都有注释:

extends CharacterBody2D enum State { SPAWNING, ACTIVE, DYING } const SPEED := 80.0 @export var damage := 1 @export var hp := 2 var state: int = State.SPAWNING var player: Node2D = null @onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D @onready var collision_shape: CollisionShape2D = $CollisionShape2D @onready var hitbox: Area2D = $Hitbox func _ready() -> void: animated_sprite.play("spawn") collision_shape.set_deferred("disabled", true) hitbox.monitoring = false animated_sprite.animation_finished.connect(_on_spawn_animation_finished) hitbox.area_entered.connect(_on_hitbox_area_entered) func _physics_process(delta: float) -> void: if state != State.ACTIVE: return _update_facing() if player: var direction := (player.global_position - global_position).normalized() velocity = direction * SPEED move_and_slide() func _update_facing() -> void: if player: var diff := player.global_position.x - global_position.x if abs(diff) > 0.01: animated_sprite.flip_h = diff < 0 func _on_spawn_animation_finished() -> void: state = State.ACTIVE collision_shape.set_deferred("disabled", false) hitbox.monitoring = true animated_sprite.play("walk") func _on_hitbox_area_entered(area: Area2D) -> void: if area.is_in_group("bullet"): take_damage(area.damage) func take_damage(value: int) -> void: hp -= value if hp <= 0: _die() func _die() -> void: if state == State.DYING: return state = State.DYING collision_shape.set_deferred("disabled", true) hitbox.monitoring = false animated_sprite.play("death") animated_sprite.animation_finished.connect(_on_death_animation_finished) func _on_death_animation_finished() -> void: queue_free()

如果你希望玩家引用不通过硬编码,而是在主场景里把玩家对象赋给敌人,可以在主场景刷怪的地方统一设置:

enemy.player = get_node("../Player")

或者更健壮的做法是通过组来获取,比如在_ready里写player = get_tree().get_first_node_in_group("player")。

3.3 配置主场景的刷怪逻辑

敌人本身做好后,还需要一个生成器来“刷怪”。我一般用一个简单的Timer配合一个生成点来做。主场景里放一个Marker2D标记出生点,然后挂一个Spawner脚本:

extends Node2D @export var enemy_scene: PackedScene @export var spawn_interval := 2.0 @export var max_enemies := 5 @onready var spawn_point: Marker2D = $SpawnPoint var current_enemies := 0 func _ready() -> void: $Timer.wait_time = spawn_interval $Timer.timeout.connect(_on_timer_timeout) $Timer.start() func _on_timer_timeout() -> void: if current_enemies >= max_enemies: return var enemy = enemy_scene.instantiate() enemy.global_position = spawn_point.global_position enemy.player = get_tree().get_first_node_in_group("player") enemy.damage = 1 add_child(enemy) current_enemies += 1 enemy.tree_exited.connect(func() -> void: current_enemies -= 1)

这段代码里有几个点要展开讲:

第一,enemy_scene用PackedScene导出,你只需要在Inspector里把做好的enemy.tscn拖进去即可,不用每次new。

第二,生成点用Marker2D而不是Node2D,因为Marker2D在编辑器里有箭头图标,方便确认方向,虽然这里用不上方向,但习惯以后做弓箭、炮塔出弹点都能复用。

第三,current_enemies的增减用tree_exited回调处理,比在敌人死亡脚本里调用Spawner的方法更解耦。敌人死了它自己queue_free就行,生成器自动感知数量变化。

如果你希望生成时玩家能看到一个明显的“生成预告”,可以在生成位置先放一个特效场景,比如地面上一圈光圈,1秒后敌人本体才出现。特效场景的做法是在Spawner里再实例化一个PackedScene,这里不展开,但你可以按同样的思路扩展。

3.4 不使用动画资源的备选方案:Tween实现生成效果

有的项目初期美术资源不足,没有生成动画帧。这时候别卡住,直接用Tween做“淡入+缩放”的过渡效果。

在_ready里可以这样写:

func _ready() -> void: state = State.SPAWNING collision_shape.set_deferred("disabled", true) hitbox.monitoring = false animated_sprite.modulate.a = 0.0 animated_sprite.scale = Vector2(0.5, 0.5) var tween := create_tween() tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) tween.tween_property(animated_sprite, "modulate:a", 1.0, 0.3) tween.parallel().tween_property(animated_sprite, "scale", Vector2.ONE, 0.3) tween.finished.connect(_on_spawn_animation_finished)

这个方案的优点是省美术资源,效果还意外的干净。缺点是tween的finished信号如果你中途打断,比如敌人刚生成就被场景清理,会有报错风险。所以要在_exit_tree里判断tween是否有效:

func _exit_tree() -> void: if tween && tween.is_valid(): tween.kill()

这段代码写到_exit_tree里,防止Godot在切换场景时因为tween还在运行而抛异常。Tween方案和动画帧方案可以并存,我建议先用Tween跑通逻辑,等美术补齐帧动画再切换,切换成本很低。

4. 常见问题与排查技巧实录:动画卡住、翻转抖动、碰撞穿透

4.1 信号没触发,生成动画永远播不完?

这个问题十个新手九个遇到过。表现是:敌人出生后站在原地不动,没有转换成ACTIVE状态。

排查步骤:

  • 检查SpriteFrames里spawn动画的Loop是否被勾选。如果Loop是开启的,animation_finished信号永远不会触发,生成流程卡死在SPAWNING。
  • 检查你是否在spawn动画播放结束前调用了play("walk")。如果你在动画中间切换了动画,旧的animation_finished就不会发射。不要在外部手动干预动画播放,除非你有特殊需求。
  • 检查信号连接是否成功。用animated_sprite.animation_finished.connect()时,如果AnimatedSprite2D节点被重新加载或者不是同一个实例,连接可能指向旧对象。最稳妥的排查方式是加一个print("animation finished"),看是否执行到。

另外,如果你用了AnimationPlayer而不是AnimatedSprite2D,animation_finished信号在Godot 4里可能叫AnimationPlayer.animation_finished,而且它是在动画轨道完整播放完毕后触发,如果动画设置为Loop,同样不会触发。

4.2 翻转抖动的根源与处理

翻转抖动最常见的原因是玩家和敌人的x坐标差值在0附近震荡。比如玩家站在敌人正上方(x几乎相等),敌人左右反复翻转,看起来像在抽搐。

我给的处理方式有两个:

  • 加滞回区间,比如diff绝对值小于20像素时保持上一次朝向:
var diff := player.global_position.x - global_position.x if diff > 10.0: animated_sprite.flip_h = false elif diff < -10.0: animated_sprite.flip_h = true
  • 翻转只跟实际移动方向绑定,而不是跟玩家位置绑定。如果你用了velocity作为判断依据,敌人只有在真的移动时才翻转,站立时不翻转:
if abs(velocity.x) > 10.0: animated_sprite.flip_h = velocity.x < 0

这条经验非常实用。很多动作游戏里,敌人不移动时翻转朝向反而会显得奇怪。建议你把“朝向追踪”跟“移动追踪”合并判断:如果敌人正在移动,以移动方向为准;如果敌人站着,以玩家相对方向为准。混合判断的效果最自然。

4.3 碰撞体比动画大一圈/小一圈的问题

如果你发现子弹没碰到敌人却被判定命中,或者子弹穿过了敌人但没触发伤害,通常不是代码问题,而是碰撞体的尺寸和位置没有跟着动画变化。

注意几个点:

  • 碰撞体是跟随根节点位置的,而不是跟随AnimatedSprite2D的画面中心。如果你的动画素材中心点和根节点的原点不在同一位置,碰撞体会偏移。
  • 生成动画期间如果用了scale缩放,而CollisionShape2D没有同步缩放,动画播完的瞬间碰撞体大小不变,但敌人视觉体已经恢复到1.0。如果你希望碰撞体也跟随缩放,可以在tween中同时tween碰撞体的scale。
  • 受击Area2D的CollisionShape2D建议比视觉效果小20%到30%。原因是玩家能明显看到“打中了敌人边缘特效”会更有打击感,如果判定框太小玩家打不中会恼火,太大又显得不公平。

我常用的调法:先把敌人移动到场景里,打开Debug下的Visible Collision Shapes,在运行状态下实时看碰撞体轮廓,一边调一边跑,直到手感舒服为止。

4.4 子弹穿透敌人但没触发area_entered?

这个坑多数出在层和掩码设置上。Godot的碰撞检测需要图层和后掩码匹配。比如子弹在Layer 2,敌人的Hitbox在Layer 3,那么子弹的Collision Mask必须包含Layer 3(也就是Mask的数值二进制里第3位为1),否则area_entered不触发。

另外还有个容易忽略的点:Area2D的monitoring属性和monitorable属性是两回事。敌人Hitbox的monitoring是用来检测别人,monitorable是“自己是否可以被别人检测到”。你需要确保敌人的Hitbox把monitorable打开,子弹的检测逻辑才能看到它。

再补充一个:如果你的子弹速度非常快(比如每帧移动500像素),它可能直接“跳过”了敌人的碰撞体,物理引擎默认不会检测两帧之间的穿越。这个问题在高级一点的项目里会用Continuous CD或射线检测解决。对于新手,先把子弹速度控制在200以下,或者把子弹做成Area2D而不是直接修改position,体验就会好很多。

4.5 敌人生成时和地形卡在一起?

生成点选在墙内或者障碍物内部,会导致敌人出生即被卡住,move_and_slide陷入无休止的碰撞修正。

解决方式:生成点尽量放在开放地带,或者在生成动画结束后做一次位置校正:

func _on_spawn_animation_finished() -> void: state = State.ACTIVE global_position = global_position.move_toward(player.global_position, 0.0) # 如果当前位置在碰撞体内,向外推一段距离

不过这种校正治标不治本。更靠谱的是在Spawner刷怪前做一个合法位置检查。你可以用PhysicsShapeQueryParameters来查询生成点附近是否为空,但那个实现稍微复杂,新手阶段先保证生成点在地形外就行。

还有一个好习惯:生成动画期间不开启move_and_slide,这样即使卡住也只会卡在动画阶段,而不会卡着物理修正乱弹。等动画播完再允许物理移动,代码已经在_physics_process里用state判断做了限制。

5. 生成流程的扩展思考:从“一个敌人”到“波次刷怪系统”

做完了基础敌人,你会发现这套逻辑几乎可以无缝扩展到更多玩法。我给几个我个人推荐的扩展方向,你们可以当作后续练习目标:

  • 波次刷怪:把Spawner改成WaveManager,每个波次定义一个敌人列表,按间隔生成。敌人的SpawnDetector区域可以检测玩家是否进入,进了才触发波次,避免玩家站在远处就被刷屏。
  • 敌人类型区别:用@export变量区分碰撞体大小、速度、生命值和伤害值。同一个敌人场景派生成多个变体,会比复制多个场景更省资源。
  • 死亡掉落:在_die()里实例化一个掉落物场景,掉落物用Area2D检测玩家靠近后自动吸附。这个需求在大多数Demo里都能加分。
  • 受击闪烁:用Tween在take_damage里快速闪烁modulate的颜色,比如从白闪回原色,给玩家“打到了”的视觉反馈。

你会发现,这些扩展完全没有打乱原来的状态机结构。原因就是我们在最开始就把生成、朝向、碰撞拆成了独立模块,它们之间只通过state和信号通讯。以后加任何功能,都是在ACTIVE这分支里加代码,而不是把_ready和_collision搅成一团。

6. 实操中我的一套调试流程

最后分享一套我调敌人系统时固定的调试流程,能帮你快速定位问题:

先把Debug菜单里Visible Collision Shapes和Visible Navigation开启,运行场景。先观察生成阶段:敌人出生时,碰撞体是否为灰色半透明(表示disabled);然后看动画播放到结束是否变成可见的轮廓(表示启用)。再故意让子弹发射角度偏移,确认受击区域范围和视觉匹配。最后控制玩家站到敌人左侧、右侧、正上方,观察翻转是否正常。

如果发现状态切换异常,第一件事不是看代码,而是在_ready、_on_spawn_animation_finished、_on_hitbox_area_entered这三个函数里各打一个print。日志是Godot调试的最好帮手,大多数逻辑问题通过print就能定位。

还有个小技巧:在编辑器里把Spawner的Timer的wait_time调成0.1秒,这样你能快速看到多个敌人同时生成的效果,检验性能有没有问题。敌人数量多的时候,如果出现明显卡顿,优先检查是不是每帧都在做多余的翻转计算或者不必要的信号发射。

这节练习做完后,建议你再做一个小改动:把敌人从“碰到玩家直接扣血”改成“攻击玩家”。后者需要在敌人体内再加一个靠近玩家的碰撞检测,以及攻击动画播放。这个改动会逼你把状态机再扩展一层(增加ATTACK状态),但有了本节生成动画和翻转的底子,写起来不会觉得陌生。等你能把一个敌人从生到死全流程跑通,你的Godot基础就真正扎实了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询