ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Godot 4开发2D太空射击游戏:从飞船控制到导出全流程实战

Godot 4开发2D太空射击游戏:从飞船控制到导出全流程实战 最近翻硬盘翻出来一个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里面添加四个Actionmove_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还是CPUParticles2DGodot 4提供了GPUParticles2D和CPUParticles2D两种粒子系统。太空大战里我用了GPUParticles2D做爆炸用CPUParticles2D做尾焰。原因是爆炸效果粒子数量大但持续时间短GPUParticles2D性能更好。尾焰粒子数量少但需要常驻CPUParticles2D反而更容易控制不需要担心显存和共享上下文。爆炸粒子要重点调这几个参数emission_shape球形。direction_spread360度。initial_velocity150到350之间。lifetime0.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: 1920Viewport Height: 1080Stretch Mode: canvas_itemsAspect: 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导出模板包然后本地解压提示成功但目录是空的。原因基本都是下载损坏或拦截软件误删。解决建议使用官方渠道下载不要到第三方网盘拿。下载后核对文件大小Godot 4.x全平台导出模板约为几百MB到1GB不等。如果解压后文件夹为空先关闭杀毒软件实时防护重新下载一次。用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实战案例很多问题其实你遇到之前早就有人趟过一遍了。
返回列表