1. 项目概述与核心思路最近在捣鼓一个竖版太空射击游戏也就是我们小时候在街机厅里玩的那种“打飞机”游戏。用Godot 4来做这件事感觉特别顺手尤其是它那个节点组件化的设计让游戏逻辑的搭建变得像搭积木一样清晰。上一期我们搞定了玩家飞机的基本移动和射击子弹biubiubiu地飞出去总算有点样子了。但光有靶子没有敌人这游戏就太寂寞了所以这一期的核心任务就是给我们的太空舞台“请”来一批不速之客——敌机。敌机配置听起来简单不就是放几个会动的Sprite精灵吗但真想做出点花样让游戏有挑战性和节奏感里面的门道可就多了。你不能让所有敌机都傻乎乎地直线往下冲那样玩家玩两局就腻了。我们需要不同类型的敌机有的负责当“肉盾”慢悠悠地飘下来吸引火力有的扮演“刺客”速度快、轨迹刁钻考验玩家的反应还得有偶尔出现的“精英怪”或者“BOSS”给玩家制造阶段性压力。同时敌机的出现得有章法不能一窝蜂全堆在屏幕上也不能稀稀拉拉让玩家等得无聊这就需要一套生成和管理的逻辑。基于这个思路我们这次的内容会围绕几个核心点展开首先是敌机资源的准备包括视觉素材和碰撞形状然后是敌机行为模式的编码实现多种移动和攻击逻辑接着是敌机生成器的设计用来控制敌机出现的时机、类型和频率最后为了让游戏体验更完整我们还得处理敌机被击中后的反馈比如爆炸效果和得分。我会把我在实际开发中踩过的坑、调试参数的心得都揉碎了讲给你听目标是让你看完后能独立设计出一套既有章法又有趣味的敌机系统。2. 敌机资源设计与准备在写一行代码之前准备工作至关重要。敌机不只是代码里的一个逻辑实体更是玩家在屏幕上直接交互的视觉和物理对象。2.1 视觉素材与动画规划对于2D像素风或卡通风格的竖版射击游戏敌机素材通常需要一系列不同外观的精灵图Sprite Sheets或单个PNG序列帧。这里有个关键点为不同类型的敌机设计差异化的视觉特征。比如小型侦察机可以设计得小巧、颜色单一中型攻击机可以有更复杂的轮廓和1-2种颜色大型精英机或BOSS则需要有更丰富的细节和独特的配色。这种视觉上的区分能帮助玩家瞬间判断威胁等级。在Godot中我习惯为每种敌机创建一个独立的场景Scene。例如创建一个名为Enemy_Scout.tscn的场景。在这个场景的根节点我通常会使用CharacterBody2D或Area2D节点。对于大多数只需受击和移动的敌机Area2D是更轻量、更合适的选择因为它专注于检测区域重叠如被子弹击中而不处理物理碰撞响应。注意如果你的敌机需要与玩家飞机发生物理碰撞即撞上就损血那么应该使用CharacterBody2D或RigidBody2D。但对于经典的“中弹即毁”模式Area2D配合碰撞检测是完全足够的。在根节点下添加一个Sprite2D节点来显示敌机外观。将你的敌机图片拖拽到Texture属性中。如果敌机有简单的动画比如引擎喷口闪烁你可以使用AnimatedSprite2D节点并配置好动画帧。接着添加一个CollisionShape2D节点作为子节点为其赋予一个RectangleShape2D或CapsuleShape2D形状大小要略小于精灵图的实际显示区域这样能让玩家的射击体验更公平避免“擦边”也算命中引起的挫败感。2.2 碰撞层与掩码配置这是Godot中管理物理交互的核心配置错了敌机就可能“穿模”或者该打中的打不中。选中根节点Area2D在检查器面板中找到Collision部分。Collision Layer碰撞层定义这个节点“属于”哪个层。我们应该创建一个专门的层给敌机比如将第3层命名为“enemy”。然后勾选这一层。这意味着这个节点存在于“enemy”层。Collision Mask碰撞掩码定义这个节点“会检测”与哪些层的碰撞。我们的敌机需要被玩家的子弹击中所以它需要检测子弹所在的层。假设我们把玩家的子弹放在第2层layer 2命名为“player_bullet”那么敌机的Collision Mask就应该勾选第2层。这样配置后当一颗位于“player_bullet”层的子弹进入敌机Area2D的碰撞形状范围内Godot就会检测到这次重叠并可以触发相应的信号如area_entered。2.3 定义敌机属性与自定义资源不同的敌机有不同的生命值、速度、得分。硬编码在场景里不利于管理和平衡调整。Godot的“自定义资源Custom Resource”功能在这里大放异彩。我们可以创建一个脚本继承自Resource来定义敌机的属性数据。创建一个新的GDScript文件命名为enemy_stats.gdextends Resource class_name EnemyStats export var health: int 1 export var score_value: int 100 export var speed: float 100.0 # 可以扩展更多属性比如子弹类型、移动模式编号等然后在你的敌机场景根节点脚本中可以添加一个export变量来引用这个资源extends Area2D export var stats: EnemyStats var current_health: int func _ready(): if stats: current_health stats.health这样你可以在场景编辑器中为每一个敌机实例单独配置一个EnemyStats资源灵活地设置其属性而无需修改代码。后期调整游戏平衡性时只需修改这些资源文件所有引用该资源的敌机实例都会自动更新非常高效。3. 敌机行为模式编码实现敌机不能是静态的装饰品它们必须动起来并且按照我们设定的模式行动。这是游戏玩法的核心来源。3.1 基础移动模式我们首先实现几种经典的移动模式。在敌机场景的脚本中例如Enemy_Scout.gd我们可以定义一些变量和函数来控制移动。1. 直线下落这是最简单的模式。在_process(delta)函数中不断更新位置即可。extends Area2D export var stats: EnemyStats var current_health: int export var move_speed: float 150.0 # 可以通过stats.speed覆盖 func _ready(): if stats: current_health stats.health move_speed stats.speed func _process(delta): # 向下移动 position.y move_speed * delta # 如果移出屏幕底部可以排队删除以释放资源 if position.y get_viewport_rect().size.y 50: queue_free()2. 正弦波移动让敌机沿着波浪线路径移动增加躲避难度。extends Area2D export var stats: EnemyStats var current_health: int export var move_speed: float 100.0 export var amplitude: float 50.0 # 波浪幅度 export var frequency: float 2.0 # 波浪频率 var time: float 0.0 var start_x: float func _ready(): if stats: current_health stats.health move_speed stats.speed start_x position.x func _process(delta): time delta # 水平方向按正弦规律移动垂直方向匀速下落 position.x start_x amplitude * sin(time * frequency) position.y move_speed * delta if position.y get_viewport_rect().size.y 50: queue_free()3. 冲锋模式敌机先缓慢移动进入一定范围后突然加速冲向玩家。这需要获取玩家位置。extends Area2D export var stats: EnemyStats var current_health: int export var normal_speed: float 80.0 export var charge_speed: float 300.0 export var charge_distance: float 300.0 # 触发冲锋的距离 var is_charging: bool false var player_node: Node2D func _ready(): if stats: current_health stats.health # 假设玩家场景的根节点名为“Player”且存在于场景树中 player_node get_node(/root/GameScene/Player) if not player_node: print(未找到玩家节点) func _process(delta): if not player_node: return var direction_to_player: Vector2 (player_node.global_position - global_position).normalized() var distance_to_player: float global_position.distance_to(player_node.global_position) if not is_charging and distance_to_player charge_distance: is_charging true var current_speed charge_speed if is_charging else normal_speed # 冲锋时朝玩家方向移动否则只是向下 var velocity direction_to_player * current_speed if is_charging else Vector2.DOWN * normal_speed position velocity * delta if position.y get_viewport_rect().size.y 50: queue_free()3.2 敌机攻击逻辑有的敌机不仅会移动还会发射子弹。我们需要为这类敌机添加攻击能力。首先需要准备敌机子弹的场景类似于玩家子弹但碰撞层应设置为能击中玩家例如 layer 1: “player”。然后在敌机脚本中管理攻击。extends Area2D export var stats: EnemyStats var current_health: int export var bullet_scene: PackedScene export var fire_rate: float 1.0 # 每秒发射次数 var fire_timer: float 0.0 func _ready(): if stats: current_health stats.health func _process(delta): # ... 移动逻辑 ... # 攻击逻辑 fire_timer - delta if fire_timer 0 and bullet_scene: shoot() fire_timer 1.0 / fire_rate # 重置计时器 func shoot(): var bullet_instance bullet_scene.instantiate() # 设置子弹的初始位置通常在敌机前方 bullet_instance.global_position global_position Vector2(0, 20) # 设置子弹的移动方向例如向下或朝向玩家 bullet_instance.direction Vector2.DOWN # 将子弹添加到场景中通常添加到主游戏场景或一个专门的子弹容器里 get_parent().add_child(bullet_instance)实操心得大量子弹的实例化instantiate和释放queue_free可能带来性能开销。一个常见的优化是使用对象池Object Pooling。预先创建一定数量的子弹节点并禁用需要时启用并设置位置子弹失效后再次禁用而非删除。对于中小型项目如果子弹数量不多直接实例化也可以但务必在子弹飞出屏幕后及时queue_free()。3.3 受击与销毁反馈当敌机被子弹击中时需要做出反应减血、播放受击特效如闪烁、生命值归零时播放爆炸动画并加分、最后移除。首先连接Area2D的area_entered或body_entered信号取决于子弹是Area2D还是CharacterBody2D。extends Area2D export var stats: EnemyStats var current_health: int export var hit_effect: PackedScene # 受击特效场景 export var explosion_effect: PackedScene # 爆炸特效场景 func _ready(): if stats: current_health stats.health # 连接信号 area_entered.connect(_on_area_entered) func _on_area_entered(area: Area2D): # 假设子弹有一个“damage”属性 if area.has_method(“get_damage”): take_damage(area.get_damage()) func take_damage(amount: int): current_health - amount # 播放受击效果比如让精灵闪烁 _play_hit_effect() if current_health 0: die() func _play_hit_effect(): # 简单实现让精灵变白一下再恢复 $Sprite2D.modulate Color.WHITE # 使用一个计时器来恢复颜色 var timer get_tree().create_timer(0.05) await timer.timeout $Sprite2D.modulate Color(1, 1, 1, 1) # 恢复原色 # 或者实例化一个受击特效场景 if hit_effect: var effect hit_effect.instantiate() effect.global_position global_position get_parent().add_child(effect) func die(): # 播放爆炸特效 if explosion_effect: var explosion explosion_effect.instantiate() explosion.global_position global_position get_parent().add_child(explosion) # 通知游戏逻辑加分例如通过一个全局信号或直接调用GameManager GameManager.add_score(stats.score_value) # 移除敌机自身 queue_free()这里的关键是解耦。敌机只负责自己受伤和死亡时的表现播放特效、移除自己而加分、更新UI这类全局事件最好通过一个单例如GameManager或信号来传递避免场景间的直接硬引用让代码更清晰、更易维护。4. 敌机生成器Spawner系统设计单个敌机场景准备好了接下来就需要一个“导演”来指挥它们何时、何地、以何种方式登场。这就是敌机生成器。4.1 基础生成器实现创建一个新的场景根节点为Node2D命名为EnemySpawner。它的核心逻辑是在特定时间间隔、特定位置生成敌机。extends Node2D export var enemy_scenes: Array[PackedScene] [] # 可生成的敌机类型池 export var spawn_interval: float 2.0 # 生成间隔秒 export var spawn_position_range: Vector2 Vector2(50, 400) # 生成X轴范围从左侧50像素到400像素 var spawn_timer: float 0.0 func _ready(): # 可以在这里初始化或者由外部控制开始生成 pass func _process(delta): spawn_timer - delta if spawn_timer 0: spawn_enemy() spawn_timer spawn_interval # 重置计时器 func spawn_enemy(): if enemy_scenes.size() 0: return # 随机选择一种敌机 var random_index randi() % enemy_scenes.size() var enemy_scene enemy_scenes[random_index] var enemy_instance enemy_scene.instantiate() # 随机生成位置屏幕上方之外随机X坐标 var spawn_x randf_range(spawn_position_range.x, spawn_position_range.y) enemy_instance.global_position Vector2(spawn_x, -50) # Y坐标从屏幕上方-50开始 # 将敌机添加到场景树通常是生成器节点的父节点或一个专门的容器 get_parent().add_child(enemy_instance)这个基础版本实现了固定间隔的随机生成但缺乏节奏感和关卡设计。4.2 波次Wave与关卡设计一个有趣的射击游戏敌机出现应该是分波次的每一波有不同的配置、强度和节奏。我们可以设计一个“波次”数据结构和生成器来管理。首先定义一个波次资源wave_data.gdextends Resource class_name WaveData export var wave_name: String “” export var enemy_list: Array[PackedScene] [] # 这一波会出现的敌机类型 export var spawn_delay: float 1.0 # 波次内敌机生成间隔 export var total_enemies: int 10 # 这一波敌机总数 export var wait_after_wave: float 3.0 # 本波结束后等待多久开始下一波然后增强我们的EnemySpawnerextends Node2D export var waves: Array[WaveData] [] var current_wave_index: int 0 var current_wave: WaveData var enemies_spawned_in_wave: int 0 var wave_timer: float 0.0 var is_wave_active: bool false func _ready(): start_next_wave() func _process(delta): if not is_wave_active: return wave_timer - delta if wave_timer 0 and enemies_spawned_in_wave current_wave.total_enemies: # 生成一个敌机 spawn_enemy_from_wave() enemies_spawned_in_wave 1 wave_timer current_wave.spawn_delay elif enemies_spawned_in_wave current_wave.total_enemies: # 这一波生成完毕检查是否所有敌机都被消灭可以通过信号或全局计数器 # 这里简化处理直接等待后进入下一波 var check_timer get_tree().create_timer(current_wave.wait_after_wave) await check_timer.timeout start_next_wave() func start_next_wave(): if current_wave_index waves.size(): print(“所有波次已完成”) # 可以触发游戏胜利或循环 current_wave_index 0 current_wave waves[current_wave_index] enemies_spawned_in_wave 0 wave_timer 0.0 # 立即开始生成第一个 is_wave_active true print(“开始波次: “, current_wave.wave_name) current_wave_index 1 func spawn_enemy_from_wave(): if current_wave.enemy_list.size() 0: return var random_index randi() % current_wave.enemy_list.size() var enemy_scene current_wave.enemy_list[random_index] var enemy_instance enemy_scene.instantiate() var spawn_x randf_range(50, 400) # 示例范围 enemy_instance.global_position Vector2(spawn_x, -50) get_parent().add_child(enemy_instance)通过编辑WaveData资源数组你可以轻松设计出整个关卡的敌机出现序列第一波全是小飞机第二波混入中型机第三波出现一个慢速但血厚的精英机等等。4.3 生成路径与行为注入有时我们希望敌机不是从屏幕顶部随机位置出现而是沿着特定路径飞入。我们可以使用Path2D和PathFollow2D节点来实现。在EnemySpawner场景中添加一个Path2D节点并用曲线工具绘制一条敌机飞入的路径。在生成敌机时实例化敌机后不直接设置global_position而是将其作为PathFollow2D的子节点。在敌机脚本中新增一个“沿路径移动”的模式通过更新PathFollow2D的progress_ratio属性来移动。这为敌机行为增加了更多可能性比如让敌机编队沿复杂曲线飞入然后散开。踩坑记录PathFollow2D的rotation_mode属性很重要。如果你希望敌机在沿路径移动时其朝向始终与路径切线方向一致比如飞机头始终朝向前进方向需要将其设置为ORIENTED。如果敌机是圆形的或朝向不重要可以保持NONE。5. 性能优化与调试技巧当敌机数量多起来特效和子弹满天飞时性能问题就会浮现。这里分享几个实战中总结的优化点和调试方法。5.1 对象池管理如前所述对于频繁创建和销毁的对象子弹、敌机、特效对象池是首选优化方案。创建一个ObjectPool单例或管理器负责预初始化一批对象提供“借出”和“归还”接口。# 简化的对象池示例 extends Node class_name BulletPool var bullet_pool: Array[Node2D] [] export var bullet_scene: PackedScene export var pool_size: int 50 func _ready(): for i in range(pool_size): var bullet bullet_scene.instantiate() bullet.visible false bullet.process_mode Node.PROCESS_MODE_DISABLED # 禁用处理以节省性能 add_child(bullet) bullet_pool.append(bullet) func get_bullet() - Node2D: for bullet in bullet_pool: if not bullet.visible: bullet.visible true bullet.process_mode Node.PROCESS_MODE_INHERIT return bullet # 如果池子空了动态扩容一个可选 var new_bullet bullet_scene.instantiate() add_child(new_bullet) bullet_pool.append(new_bullet) return new_bullet func return_bullet(bullet: Node2D): bullet.visible false bullet.process_mode Node.PROCESS_MODE_DISABLED bullet.global_position Vector2(-1000, -1000) # 移到屏幕外敌机生成器或射击逻辑调用get_bullet()获取一个可用的子弹设置其位置和方向后激活。子弹击中目标或飞出屏幕后调用return_bullet(bullet)将其放回池中。5.2 可视范围裁剪与自动回收Godot的VisibleOnScreenNotifier2D节点非常有用。将其添加到敌机或子弹场景中可以连接到它的screen_exited信号当节点完全离开屏幕视口时自动调用queue_free()或放回对象池。这能有效防止离开屏幕的节点继续消耗计算资源。# 在敌机或子弹的 _ready 函数中 $VisibleOnScreenNotifier2D.screen_exited.connect(_on_screen_exited) func _on_screen_exited(): # 对于对象池管理的对象调用 pool.return_object(self) # 对于直接实例化的对象 queue_free()5.3 调试与平衡性调整调试绘制Debug Draw在开发过程中开启Godot编辑器中的调试选项非常有用。在“调试”菜单中可以开启“可见碰撞形状”这样所有CollisionShape2D都会以轮廓线显示在游戏窗口中方便你检查碰撞框是否与精灵图匹配。性能分析器使用Godot内置的性能分析器“调试器”面板中的“性能”选项卡。重点关注“对象计数”、“物理2D时间”和“脚本时间”。如果敌机数量上升时这些指标急剧增长就需要考虑优化如对象池、简化碰撞形状、减少每帧的复杂计算。平衡性数据外部化务必把所有敌机的生命值、速度、伤害、生成间隔、波次配置等数据都做成可导出的资源如EnemyStats,WaveData。这样调整游戏难度和节奏时你只需要在编辑器中修改几个数字或拖拽几个资源而无需重新编译脚本。这是快速迭代游戏体验的关键。信号替代直接引用尽量使用Godot的信号系统来传递事件比如“敌机被摧毁”、“玩家得分”。这能极大降低场景节点间的耦合度。你的EnemySpawner不需要知道GameManager是谁它只需要在适当的时候发出一个wave_completed信号由GameManager去监听并处理。这让代码结构更清晰也更容易进行单元测试。敌机系统的搭建是赋予游戏灵魂的重要一步。从呆板的移动靶子到有组织、有策略、有挑战的敌人军团中间隔着对细节的不断打磨。我自己的习惯是每实现一种新的敌机行为都会单独创建一个测试场景去反复调整它的参数直到它的行为让我觉得“有趣”而不是“烦人”或“无聊”。然后才把它加入到正式的波次配置中。记住好的敌机设计是让玩家在一次次失败中还想“再来一局”的关键。