☰
Godot 4开发2D太空射击游戏:从飞船控制到导出全流程实战
2026/9/28 5:35:59 网站建设 项目流程

最近翻硬盘翻出来一个Godot 4的老项目,文件名就叫“太空大战”,是用Godot引擎写的2D太空射击小游戏。当年在它身上花了不少时间,从玩家飞船的移动到敌机波次,再到粒子爆炸效果,几乎把Godot 2D的常用模块都过了一遍。前两天还有朋友问“Godot到底适不适合做射击游戏”,正好借这个项目把整个实现思路拆开聊聊,顺便把几个容易踩的坑也一并说了。

如果你正打算用Godot做自己的第一款游戏,或者想从Unity转过来试试GDScript,这篇可以直接当参考。项目不是那种商业级的成品,但麻雀虽小五脏俱全:飞船控制、敌机AI、波次生成、碰撞判定、视差星空、粒子特效,外加导出前的窗口设置和中文字体乱码处理,全都有。单机为主,没有联机需求,正好能把Godot 2D的核心用法摸透。

1. 为什么我会用Godot来做这款太空射击游戏

先说说选型。太空大战的核心玩法是“控制飞船左右移动+向上射击+躲避敌机”,属于非常典型的2D射击游戏。这类游戏的物理、碰撞、动画逻辑都不复杂,关键是迭代速度要快,场景组织要清晰,而Godot在这些方面体验相当舒服。

1.1 引擎选型:从Unity转来的真实理由

我用过Unity,也看过Cocos,最后在Godot 3.x到4.x时期切过来,理由其实很朴素:

  • 编辑器本体小,启动快,一个2D项目随时打开随时改,不用等那漫长的编译。
  • 场景树的继承和组合非常符合2D游戏的组织习惯,一个敌机就是一个场景文件,把它实例化扔进关卡里,逻辑全部跟着走。
  • GDScript的手感很像Python,写起来没有强类型负担,做原型阶段效率极高。
  • 内置节点对2D支持很友好,CharacterBody2D、Area2D、ParallaxBackground这些都是现成的,不需要额外插第三方框架。

你要说Godot有没有缺点?也有。比如调试器在某些版本里不够稳定,动画树编辑器比Unity复杂,还有一些物理回滚的兼容性问题(后面会专门说)。但对于太空大战这种项目,Godot 4.2以上版本已经完全够用了。

1.2 太空大战的最小可玩版本清单

开始写代码之前,先列了一份最小可玩版本(MVP)清单,把所有功能拆成下面几块,开发时就不会东一榔头西一棒子。

模块核心节点说明
玩家飞船CharacterBody2D移动、射击、被击中后爆炸
敌对单位Area2D / CharacterBody2D不同飞行行为,碰撞判定集中在Area2D
波次生成器Node定时生成敌机,控制游戏节奏
子弹Area2D玩家和敌机共用一套子弹池
UICanvasLayer + Label分数、生命、开始/结束界面
背景ParallaxBackground视差星空,增加太空深度感
特效GPUParticles2D / CPUParticles2D爆炸、飞船尾焰、子弹命中火花

这套清单排好以后,后面的工作基本就是按表格往里面填内容。很多人做游戏容易飘,一开始就想着做Boss战、武器升级、多角色,其实没有必要。先把主循环跑通:玩家能移动、能射击、敌机能死、能计分,这个循环有了,其他玩法都是在这个骨架上长出来的。

2. 玩家飞船:从节点树到控制手感的调校

太空大战给人的第一印象,其实就是飞船的手感。如果飞船又肉又飘,射击游戏就废了一半。所以这一块我花的时间最多,不是功能难,而是参数怎么调。

2.1 玩家飞船场景节点结构

飞船我直接用CharacterBody2D做根节点,因为它自带move_and_slide()方法,处理移动碰撞非常方便。飞船本身不需要模拟重力,也不需要被其他物理物体弹开,只需要自己控制速度。

节点树大概是这个样子:

Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D ├── Area2D (Hitbox) │ └── CollisionShape2D ├── Muzzle (Marker2D) ├── GPUParticles2D (EngineTrail) └── AudioStreamPlayer2D (ShootSound)

注意这里我同时挂了两个碰撞节点:CharacterBody2D自带的CollisionShape2D用来处理飞船和地图边界、障碍物;Area2D用来做受伤判定。这样做的原因是两类碰撞的目的不一样,前者是物理交互,后者是“事件探测”,不应该混在同一个碰撞层级里。

Player.gd的核心代码就一小段:

extends CharacterBody2D const SPEED := 420.0 const ACCELERATION := 2200.0 const FRICTION := 1800.0 var move_input := Vector2.ZERO func _process(_delta: float) -> void: move_input = Input.get_vector("move_left", "move_right", "move_up", "move_down") func _physics_process(delta: float) -> void: if move_input == Vector2.ZERO: velocity = velocity.move_toward(Vector2.ZERO, FRICTION * delta) else: velocity = velocity.move_toward(move_input * SPEED, ACCELERATION * delta) move_and_slide()

这里有个细节:我用了move_toward而不是直接给velocity赋值。如果直接赋值,飞船就是瞬间达到最高速度,按一下键就满速,松一下键立刻停住,操作起来像在拨开关,完全没有惯性。move_toward让速度以一个固定速率逼近目标,按下方向键的时候飞船“逐渐加速”,松开后“逐渐滑行”,反而更有宇宙飞船那种在真空里受推进器控制的感觉。

2.2 输入映射:为什么不用Input.is_key_pressed

很多人写移动习惯直接在代码里写Input.is_key_pressed(KEY_A),这样最快,但换个手柄就废了。Godot正确做法是用InputMap。

在Project Settings -> Input Map里面添加四个Action:

  • move_left: A / 左方向键 / 手柄左摇杆左
  • move_right: D / 右方向键 / 手柄左摇杆右
  • move_up: W / 上方向键 / 手柄左摇杆上
  • move_down: S / 下方向键 / 手柄左摇杆下
  • shoot: 空格 / 手柄X键

这样一来,游戏逻辑代码里只需要读取Action,不需要关心玩家用的是键盘、手柄还是触屏。后续想加双人模式,只需要给第二个玩家再映射一套不同的Action就行,非常方便。

2.3 加速度、阻尼和目标速度的调参经验

手感这种主观的东西,有经验参数可以直接套。我试了一轮之后,最终用的参数如下:

参数初始尝试最终值手感变化
最大速度600420太快导致躲不开子弹
加速度60002200瞬间满速像瞬移,没有推进感
摩擦01800有轻微滑行,但不失控
子弹间隔0.25秒0.18秒提高爽快感,又不至于太强

这个表不是给你死记的,而是告诉你一个思路:调手感一定要一次只改一个参数,改完立刻试玩十几秒。改速度的时候就别动加速度,改加速度的时候也暂时忽略速度变化。否则你根本不知道是哪个参数导致“跑起来像溜冰”。

2.4 子弹池:高射速下的性能保障

太空大战的子弹发射频率很高,如果每发子弹都去instantiate生成节点,打几秒钟GC就开始卡顿。优化思路是提前创建一批子弹节点放进数组,需要的时候从数组里取一发“现成的”。

const BULLET_SCENE := preload("res://scenes/Bullet.tscn") const MAX_BULLETS := 40 var bullet_pool: Array[Area2D] = [] func _ready() -> void: for i in MAX_BULLETS: var b := BULLET_SCENE.instantiate() b.is_active = false b.visible = false add_child(b) bullet_pool.append(b)

发射时遍历数组找一个is_active为false的子弹来用。没有空闲子弹就直接跳过这个波次,而不是无限创建。实际测试下来,40发子弹的池子完全够用,再配合Timer的0.18秒间隔,不会出现全池打空的情况。

另外,子弹的物理碰撞不要用RigidBody2D。子弹本身不需要受力,用Area2D做纯检测就对了。RigidBody2D会额外承担物理模拟性能,对高速飞行的小体积对象来说完全没必要。

3. 敌机行为与波次生成:让Galaxian式空战有节奏

敌机是太空大战的“敌人”,也是游戏节奏的控制器。如果敌机只是从上往下匀速飞,玩家玩三十秒就腻了。所以我把敌机拆成三种基础行为,再通过波次生成器组合。

3.1 三种常见敌机行为

  • 直线俯冲型:从屏幕顶端匀速向下飞,速度固定,风险最低。
  • 正弦摆动型:水平方向以sin值左右摆动,躲避起来更有难度。
  • 追踪型:向玩家当前方向缓慢转向,威胁最大,但血量最少。

实现上其实不用写三个独立脚本,用一个Enemy.gd,通过预设的枚举变量来切换行为。

extends Area2D enum EnemyBehavior { STRAIGHT, SINE, HOMING } @export var behavior: EnemyBehavior = EnemyBehavior.STRAIGHT @export var speed := 180.0 @export var sine_amplitude := 240.0 @export var sine_frequency := 3.0 var _base_position := Vector2.ZERO var _time := 0.0 func _process(delta: float) -> void: _time += delta match behavior: EnemyBehavior.STRAIGHT: position.y += speed * delta EnemyBehavior.SINE: _base_position.y += speed * delta position.x = _base_position.x + sin(_time * sine_frequency) * sine_amplitude position.y = _base_position.y EnemyBehavior.HOMING: var dir := (Global.player.position - position).normalized() position += dir * speed * delta

把这个脚本挂到一个基础敌机场景上,然后在波次生成器里生成时指定behavior,就能快速生成各种组合。这里用了一个全局单例Global来存玩家引用,对于这种小规模项目最直接。如果项目再大一点,建议用组(add_to_group("player"))来查找,避免全局单例满天飞。

3.2 用Resource做波次配置表

早期版本我是把波次配置直接写在Spawner.gd里的,一个巨大的嵌套数组。后来改用一个Godot Resource子类作为波次配置资源,把每波该生成什么敌机、间隔多久、数量多少,全部可视化地放在Inspector里编辑。

class_name SpawnWaveResource extends Resource @export var enemy_scene: PackedScene @export var spawn_count := 5 @export var spawn_interval := 0.8 @export var time_between_waves := 2.0

然后在Spawner里直接用export数组引用这些资源。

@export var waves: Array[SpawnWaveResource] = []

这样做的好处是,游戏策划或者你自己以后想调关卡,不需要改代码,只需要在资源文件里改数字就行。这也是Godot和Unity都推荐的数据驱动思路:逻辑代码和数据配置分离。

3.3 信号与碰撞判定:不是每帧去查

敌机的死亡判定和玩家受伤判定,我用的是Area2D的body_entered和area_entered信号,而不是每帧遍历所有子弹去查位置。

func _on_hitbox_area_entered(area: Area2D) -> void: if area.is_in_group("bullet"): hp -= 1 area.queue_free() if hp <= 0: _explode()

这里有个容易踩的坑:Area2D信号触发的前提是,双方都必须有合适的CollisionShape2D,而且layer/mask要匹配。否则你连了信号,游戏里怎么打都触发不了。我当时为了这个问题排查了半小时,最后发现敌机的layer在1,玩家子弹的mask也在1,按道理能碰到,但两个Area2D还需要在Project Settings -> Physics 2D里把对应的碰撞层勾上才算完整闭环。

3.4 波次推进与游戏难度曲线

波次生成器内部就是一个Timer加一个索引。每次计时器超时,就取当前波次配置生成敌机。等wave_index走完,进入轮换模式,随机挑选两种波次的组合,并在原有基础上给敌机增加5%速度,让难度持续上升。

这个逻辑切记不要写死在_ready里,用信号暴露wav_cleared事件,由GameManager这个控制节点统一管理状态。GameManager根据当前波数决定UI上显示“WAVE 1”“WAVE 2”,还能顺便控制Boss战触发。

4. 太空大战里避不开的Physics 2D和碰撞细节

玩过射击游戏的都知道,碰撞检测如果没弄好,游戏会出现“明明打中了却不结算伤害”“子弹穿过了敌机”这种终生难忘的问题。Godot的物理系统不像商业大厂引擎那么“自动帮你搞定一切”,理解它的机制能规避很多奇怪现象。

4.1 物理层与layer/mask:为什么子弹会打到自己

Godot 2D物理碰撞有layer和mask两个概念:

  • layer:你这物体属于哪一层。
  • mask:你会与哪些层发生碰撞。

如果一个物体layer设成1,它就在第1层。mask设成2,它只和属于第2层的物体碰撞。这看起来很简单,但很多新手把所有物体都放在默认层,看起来能碰撞,其实碰撞关系完全不可控。

太空大战里我这样划分:

对象LayerMask说明
玩家本体12玩家碰撞体只与敌机层碰撞
敌机21 + 3会与玩家和所有子弹碰撞
玩家子弹32只打敌机
敌方子弹41只打玩家

这样设置之后,玩家子弹不会打到自己,敌方子弹不会打到敌机,各层的碰撞责任非常清晰。

4.2 高速子弹碰撞遗漏与解决方案

Godot 2D默认的碰撞检测并不是continuous,也就是指在物理帧之间,如果物体速度太快,可能“跨过”一个薄薄的碰撞体。

我当时测试时把子弹速度调到1200px/s,结果经常穿过薄壁石头墙。后来用了一个很实用的替代方案:把子弹的CollisionShape2D变成一个偏长的矩形,长度方向沿子弹飞行方向。这样一个物理帧之间,子弹经过的空间也能覆盖到碰撞区域。虽然精度比不上Raycast逐帧检测,但对2D射击游戏来说完全够用,而且性能开销极低。

如果你的游戏需要极高的命中精度,比如弹幕类、狙击类,还可以用Raycast2D做一次“扫描”,或者干脆用多个小步移动模拟连续移动。但太空大战这种游戏,没必要为一次物理跨越搞这么复杂。

4.3 顺带聊一下Physics Rollback:跨平台回滚不干净是怎么回事

如果你把太空大战做成联机版,可能就会用到回滚式同步。这个词有点专业,简单说就是:为了延迟补偿,每个客户端先在本地模拟游戏,然后在收到正确同步数据后回滚到之前的状态重新模拟。

我尝试给一个原型加过回滚,结果在Windows上测试没毛病,但在Linux和macOS上经常出现“回滚不干净”的现象,具体表现是:敌机炸了之后又复活、玩家位置错位、子弹闪烁。

后来排查原因,主要是这几个方面:

  • 浮点精度在不同平台和架构上有细微差异,物理模拟走不了几步就分叉。
  • Godot的PhysicsServer内部状态很多,单纯记录节点position和velocity是不够的,还需要记录Area2D的monitoring、碰撞层状态等。
  • 物理帧回调顺序在跨平台上不完全一致,导致快照恢复后状态顺序错乱。

如果真要做物理回滚,建议只记录“游戏逻辑状态”而不是依赖引擎物理状态。太空大战这类游戏的逻辑状态其实很薄,只有敌机位置、子弹位置、血量、分数这些固定变量,完全可以自己维护一套快照,而不是去快照引擎的物理世界。把物理的活都交给Area2D信号,剩下的状态自己管理,反而不会有回滚问题。

4.4 伤害流程:一次完整的中弹与爆炸处理

玩家被敌机撞到或被敌方子弹打中,需要经过统一的伤害入口,而不是在多个信号处理函数里改数值。

我设计了一个Global.damage_player(amount)单例方法:

signal player_damaged func damage_player(amount: int) -> void: if invincible: return current_hp -= amount player_damaged.emit() if current_hp <= 0: get_tree().reload_current_scene()

所有碰撞信号最终都调这个入口,就不会出现“被撞一次却减了两滴血”的Bug。同时使用瞬移无敌时间(invincible)来防止连续判定,这个在射击游戏里几乎是标配。

5. 把“太空”这个氛围做出来:星空、粒子、屏幕抖动与音效

太空大战的代码玩法做完后,画面还很干,就是纯黑背景加一架飞船。我后来花了两天把氛围做起来,效果立刻不一样。这几个技巧都不难,但非常值钱。

5.1 视差滚动星空:两行代码营造深度

直接用Godot的ParallaxBackground节点,加两个Parallax2D子节点,一个是远景小星星,一个是近景大星星。

关键参数:

  • Parallax2D的scroll_scale:远景设0.2,近景设0.8。
  • 星星Texture填一张自己用Script画的星点图,或者直接用简单的点阵纹理。
  • Repeat属性打开,保证背景可以循环滚动。
extends Parallax2D @export var scroll_speed := 20.0 func _process(delta: float) -> void: scroll_offset.y += scroll_speed * delta

两个Parallax2D分别用不同速度滚动,玩家移动时背景星星也有不同速率的相对运动,太空感一下就出来了。

这里有个细节:ParallaxBackground默认会填满视口,所以不需要手动设置大小,确保它位于CanvasLayer之下即可。

5.2 爆炸粒子与飞船尾焰:GPUParticles2D还是CPUParticles2D

Godot 4提供了GPUParticles2D和CPUParticles2D两种粒子系统。太空大战里我用了GPUParticles2D做爆炸,用CPUParticles2D做尾焰。

原因是:

  • 爆炸效果粒子数量大但持续时间短,GPUParticles2D性能更好。
  • 尾焰粒子数量少但需要常驻,CPUParticles2D反而更容易控制,不需要担心显存和共享上下文。

爆炸粒子要重点调这几个参数:

  • emission_shape:球形。
  • direction_spread:360度。
  • initial_velocity:150到350之间。
  • lifetime:0.4到0.8秒。
  • scale_curve:从1衰减到0.2。
  • color_ramp:从黄色到橙色再到透明。

在太空里没有空气阻力,爆炸粒子其实应该直接向四面八方匀速飞溅,不需要任何阻尼衰减,这样反而更像真空中爆炸。

5.3 屏幕抖动:最容易出效果的回馈武器

飞船被击中、敌机爆炸、Boss撞击,都可以触发屏幕抖动。实现方式简单粗暴,给Camera2D加一个offset抖动逻辑。

extends Camera2D var trauma := 0.0 func add_trauma(amount: float) -> void: trauma = min(trauma + amount, 1.0) func _process(delta: float) -> void: if trauma > 0: trauma = max(trauma - delta * 1.5, 0.0) offset = Vector2( randf_range(-1.0, 1.0) * trauma * 20.0, randf_range(-1.0, 1.0) * trauma * 20.0 ) else: offset = Vector2.ZERO

在任意伤害入口调用add_trauma(0.3),就能有很不错的反馈。注意:相机抖动用的是offset而不是position,这样不会干扰Camera2D的平滑跟随逻辑。

5.4 音效触发与音量衰减:AudioStreamPlayer2D的坑

太空大战里每艘敌机爆炸都放音效,如果每个敌机都挂一个AudioStreamPlayer2D,一次性十个敌机爆炸,CPU会短暂卡一下。更好方案是使用一个“音效池”或者使用AudioStreamPlayer2D的多实例。

简单做法:建立一个SoundManager单例,预加载音效,然后调用play_sound(path, position)时,从池子里取出一个空闲的AudioStreamPlayer2D,设置位置并播放。用完再还回池子。

另外,AudioStreamPlayer2D的衰减距离在太空场景里不好把握。太空是真空,声音不应该传播,但游戏需要玩家听到爆炸,所以通常会把attenuation调小,甚至设置为0,纯当全局音效用。别太较真物理规则,游戏体验优先。

6. 窗口设置、中文乱码和导出:上架前最容易被问的三件事

玩法、画面、音效都做完了,最后要打包发布,或者给别人试玩。这时候才是新人最容易卡住的环节。以下三个问题我敢说你迟早会遇到。

6.1 窗口与视口设置:固定分辨率还是自适应

很多人做游戏习惯把窗口设置为固定1280x720,然后导出后发现别人的显示器是4K或带鱼屏,游戏要么被拉伸变形,要么两边黑一大片。

我最后用的是自适应方案:

  • Viewport Width: 1920
  • Viewport Height: 1080
  • Stretch Mode: canvas_items
  • Aspect: expand

设置后,游戏画布以1920x1080为基准,但窗口比例变化时,画布宽度自动扩展,不会缩放变形。界面UI如果想固定在角落,可以用Control的锚点系统锁定。

全屏切换可以用代码:

Display.window_mode = Display.WINDOW_MODE_FULLSCREEN

这个功能一定要绑定到选项菜单里,玩家用不同的屏幕比例时,全屏才能获得最舒服的体验。

6.2 Godot引擎游戏乱码:为什么中文显示成方块

Godot 4自带的界面字体默认只支持拉丁字符,你直接在Label里写中文,导出后十有八九是方框乱码。解决办法是:

  • 准备一个支持中文的字体文件,比如思源黑体或开源中文字体。
  • 在项目设置里设置Theme -> Default Theme -> Default Font,把所有控件默认字体替换成中文字体。
  • 动态文本(比如玩家昵称、排行榜)需要额外设置支持相应字符集的Fallback字体。

另外,代码文件里的字符串如果用中文,记得确保文件编码是UTF-8。Godot 4默认就是UTF-8,但如果从旧项目迁移或者用了外部编辑器,偶尔会出现编码不一致导致乱码,这种情况检查代码文件开头的BOM标志就能发现。

还有一点:如果你导出Web版,中文字体文件可能很大,加载会变慢。建议用字体子集化工具把字体裁减到只包含游戏中用到的字符,能压掉至少80%体积。

6.3 导出包体压缩与“解压0个文件”问题

有段时间很多人在社区里问“Godot导出包下载下来解压总是0个文件是怎么回事”。我当年也遇到过,现象是:从官网下载Windows导出模板包,然后本地解压,提示成功但目录是空的。

原因基本都是下载损坏或拦截软件误删。解决建议:

  1. 使用官方渠道下载,不要到第三方网盘拿。
  2. 下载后核对文件大小,Godot 4.x全平台导出模板约为几百MB到1GB不等。
  3. 如果解压后文件夹为空,先关闭杀毒软件实时防护,重新下载一次。
  4. 用7-Zip而不是Windows自带解压器,后者对分卷和部分格式兼容性较差。

导出游戏包时,我习惯勾选“压缩导出文件”选项,能明显减小体积。但要注意:开启压缩后,某些旧版本Windows机器上首次启动解压会慢一点,可通过加一个启动画面来缓解这个体验问题。

6.4 其他周边:Spine动画和VRM模型能用在太空大战里吗

如果你后续想把飞船换成更精致的角色模型,或者给敌机加入大量骨骼动画,可以考虑Spine 2D或者VRM模型。Godot对Spine有第三方导入插件,spine 3.875的槽位和动画基本可以无损导入;VRM模型则主要用于类人角色,太空大战里如果做成“驾驶员舱内视角”或“舰队指挥官对话”,倒是能用上。

不过我的建议是:本作保持纯精灵图就好。太空大战的核心是爽快的射击体验,不是角色养成。像Dialogue Manager这类做对话系统的工具,可以等游戏有了完整剧情任务线再加,否则只会拖慢开发节奏。

最后再分享一个我认为最值得抄的设置

如果这篇只能记住一个东西,我希望是那套碰撞层划分方案。很多新手在开发中小型2D游戏时完全不设置碰撞层,所有物体都堆在默认层,导致后面加敌对子弹、加Boss、加掉落的道具时,碰撞关系越来越乱,最后只能靠一堆if语句在信号回调里打补丁。

从第一个敌人开始就花十分钟把layer和mask规划好,后面每次新增碰撞对象都遵守约定,项目的可维护性会有天壤之别。太空大战开发到后期,我几乎没有为“谁打谁”的问题修过逻辑Bug,这才是这个项目留给我最值钱的经验。

如果你也正在用Godot做射击类小游戏,可以把这篇当成一个踩坑笔记。从玩家的移动手感,到Area2D信号接线,再到导出时的中文字体,每一步都有现成的方案可以抄。剩下的,就交给时间,多看社区里的Godot实战案例,很多问题其实你遇到之前,早就有人趟过一遍了。

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

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

立即咨询