ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

俯视角2D射击游戏开发全解析:Godot 4实现移动、射击与碰撞

俯视角2D射击游戏开发全解析:Godot 4实现移动、射击与碰撞 简介一份基于C与SFML/Box2D的自上而下2D射击游戏源码项目适合想了解2D游戏框架、物理引擎与光照渲染的开发者。项目除了实现基本射击玩法还重点展示了视野雾、实时阴影、光源贴图等效果并整合地图生成、玩家与敌人实体、碰撞检测、生命条等模块代码结构清晰。压缩包共76个文件、仅140KB包含15个h头文件与14个cpp源文件、21个png图片与xcf原始绘图、pgm/ppm地图和光照数据、frag着色器以及Makefile构建文件目录划分明确便于对照学习。目前已有365人学习。通过阅读这份源码可以学习Box2D物理世界与SFML渲染循环的配合方式也可参考视野和阴影的实现思路对小型游戏或课程设计有直接帮助。 做一个俯视角2D射击游戏听起来是个小项目但它几乎是所有2D游戏玩法的浓缩移动、瞄准、射击、碰撞、敌人AI、计分反馈全都在一个屏幕上跑起来。这篇文章就围绕我最近做的这个TopDownShooter项目完整拆解思路、代码、参数取舍和踩坑记录。内容以Godot 4为示例引擎不过如果你正在用Unity或者H5 2D游戏引擎比如Phaser、PlayCanvas很多东西平移过去也一样成立——因为游戏设计的核心从来不在某个引擎的特定API而在于你如何把“好玩”拆成一个个可执行的系统。1. 项目定位与设计拆解俯视角2D射击游戏到底在做什么1.1 核心玩法循环从移动、瞄准到击杀的最短路径俯视角射击英文就是TopDownShooter摄像机从角色头顶往下看玩家在一张平面地图上自由移动朝任意方向瞄准并射击。这个类型的代表作品很多像是《元气骑士》《挺进地牢》《Enter the Gungeon》它们表面上有花里胡哨的道具和关卡但骨子里的玩法循环极其简洁移动避弹、瞄准敌人、射击击杀、获得反馈、面对更强敌人循环往复。这个循环是游戏设计的“骨架”做项目第一步不是写代码而是把骨架定清楚。我的TopDownShooter只取最核心的版本一个玩家角色、一种子弹、一个敌人刷新点、一个计分器。没有道具、没有换弹、没有多武器。为什么这么砍因为最小可玩版本Minimal Viable Product的意义在于用最短时间让“移动→射击→击杀→反馈”这一条链路完整跑通之后再往上加任何系统才有意义。很多新手一上来就想做几十个敌人种类、十几个武器结果花了三个月还没解决“子弹怎么发出去”的问题。先把核心循环做好哪怕画面再简陋玩起来能让玩家“上头”就已经成功了一半。1.2 技术选型逻辑为什么我选了Godot 4而不是别的俯视角2D射击对引擎的要求其实不高任何主流2D引擎都能胜任。我选Godot 4主要看重三点节点树设计对2D游戏极度友好GDScript语法几乎零门槛项目文件轻量到一秒就能打开。CharacterBody2D、Area2D、Marker2D这些都是天然对应游戏对象的节点你不需要额外封装“实体”类拖节点挂脚本就能开干。相比之下Unity的2D物理和Tilemap也很成熟但脚本结构更重新建一个子弹可能要同时处理Prefab、Layer、Rigidbody对原型项目来说有点啰嗦。H5引擎像Phaser、PlayCanvas的好处则是天然跑在浏览器里分享极其方便如果你想把游戏发到网页给朋友直接点开玩用Phaser写一套也无妨。但我自己更推荐拿Godot做本地原型调试方便、性能损耗低、编辑器还能直接改场景结构适合像我这样喜欢边写边调的人。这里额外说一句如果你准备把游戏发布到网页Godot 4导出为H5也很流畅即使主项目用它写兼容性也不用太担心。1.3 模块清单最小可玩版本需要哪些系统把游戏拆成模块我能列出一张清晰的清单玩家控制角色移动、鼠标瞄准、射击触发。子弹系统子弹生成、方向设定、生命周期、碰撞检测。敌人系统敌人生成、追踪玩家、与子弹碰撞后销毁。碰撞分层玩家、子弹、敌人、墙壁分属不同碰撞层避免误伤和堆叠。UI与流程血量或分数显示、游戏结束条件、重新开始。手感参数移动速度、子弹速度、射速、命中反馈音效或屏幕震动。每个模块都是独立的改一个系统不影响其他系统这是项目保持清爽的底线。我在开发时把每个模块都做成了独立的场景和脚本后续如果需要加一个双枪武器只需要替换玩家的射击逻辑敌人生成和UI完全不用动。2. 核心机制细节移动、动画、子弹与碰撞2.1 移动手感八方向移动和动画帧如何取舍关于移动很多人第一反应是“2D游戏要做8向动画帧么”。这个问题要看你的移动方式。如果是网格回合制或者《以撒的结合》那种四方向瞄准4向动画完全够用如果是自由移动俯视角射击角色会朝360度任意方向跑美术上做8向动画成本极高——每套动作要做8个方向的绘制和IDLE状态整体资源量直接翻好几倍。我的做法是“旋转而不换帧”角色本体是一套带方向的站立和跑步动画代码里用look_at()让角色整体朝鼠标方向旋转动画只区分“IDLE”和“RUN”两种状态。这样既不牺牲瞄准灵活性又省掉大量美术资源。很多商业游戏也是这么干的区别只是加了更细腻的上下半身分层而已。移动本身用Input.get_vector()读取上下左右按键生成一个标准化向量再乘以速度。这个向量会自动归一化也就是说斜着走不会比直线走更快这一点非常重要——新手往往直接用两个轴的输入做velocity结果斜向移动速度变成了直线的大约1.414倍角色像开挂一样飙出去。2.2 子弹方案手写移动和物理体之间的权衡子弹是射击游戏的核心它的方案直接决定“手感”和“性能”。我用的是Area2D加手写移动而不是RigidBody2D外加引擎物理模拟。原因是子弹的飞行轨迹应该完全可控不希望它被场景里的重力、反弹、摩擦力影响而且Area2D不用参与物理模拟性能更好。具体实现上子弹每帧通过position direction * speed * delta来移动速度恒定方向固定。碰撞检测用Area2D默认的信号只要配置好碰撞层mask命中敌人就可以触发处理。手写移动唯一的风险是帧率不稳定时单帧位移可能过大导致“跳穿”碰撞体我后面会专门讲怎么处理这个问题。子弹的销毁也值得注意。很多新手只做碰撞销毁但子弹如果没打中目标就会永远飞下去时间一长性能越来越差画面全是乱飞的弹道。我加了一个2秒到3秒的生命周期超时自动queue_free()简单粗暴但绝对有效。2.3 敌人AI与2D物理层配置敌人AI在一开始可以做得非常笨每帧计算玩家位置朝玩家方向移动。只要玩家比敌人跑得快这个“追逐”本身就是可玩的。我用CharacterBody2D做敌人而不是RigidBody2D原因在于敌人之间如果都是刚体就会互相物理碰撞推挤几秒钟后在刷怪点挤成一大坨极其难看。CharacterBody2D的move_and_slide()自带软体分离separation多个敌人相遇时默认会互相让开不会完全重叠也不会产生物理弹跳。这个细节体验差距很大同样的代码量选对节点类型能省去大量调参时间。接下来是2D physics层这个必须现在就说。Godot项目设置里可以定义多个2D物理层layermask。我给项目规划了四层玩家、子弹、敌人、墙壁。子弹的layer是“子弹”mask只勾选“敌人”和“墙壁”这样子弹永远不会打到自己也不会误伤玩家。玩家的mask勾选“敌人”和“墙壁”用于处理碰到敌人掉血和撞墙阻挡。物理分层做清晰之后碰撞逻辑简单到基本不用在脚本里写if判断引擎本身就帮你过滤掉了。2.4 射击频率与手感调优参数射击游戏好不好玩很大程度取决于数字手感。几个关键参数我列出来了这是我自己调过很多遍之后觉得比较舒服的起步值射击间隔射速0.12秒到0.2秒之间太慢会憋屈太快则玩家不用瞄准瞎按就能赢。子弹速度600到900像素/秒低于500会感觉子弹很“肉”超过1200在窄屏上很难看清弹道。玩家移动速度180到250像素/秒这个数值要略低于子弹速度保证玩家确实需要走位而不是站桩输出。敌人移动速度玩家速度的60%到80%让玩家有容错空间又不会被轻易溜掉。除了数值视觉和听觉反馈也很关键。我在子弹命中时让敌人播放一个闪烁效果再加一个短促的击打音效累计到一定分数后屏幕会轻微震动。这些反馈不需要大型系统几行代码就能加但它们是“手感”的重要组成部分。3. 实战搭建一个可玩的TopDownShooter原型3.1 场景与节点结构设计我习惯先在场景面板里把所有东西的“骨架”搭出来再写脚本。主要场景Main里包含三个子场景Player玩家、EnemySpawner敌人生成器和HUD计分界面。每个子场景拆分如下Main (Node2D) ├── Player (CharacterBody2D) │ ├── Sprite2D │ ├── CollisionShape2D │ ├── Muzzle (Marker2D) # 子弹射出点 │ └── RayCast2D # 可选用于检测枪口是否被墙体挡住 ├── EnemySpawner (Timer) │ └── script: 每隔几秒生成一个敌人 ├── Enemies (Node2D) # 存放所有敌人的容器 ├── Bullets (Node2D) # 存放所有子弹的容器 └── HUD (CanvasLayer) └── ScoreLabel (Label)把子弹和敌人分别放到容器节点里而不是直接挂到根节点下是为了后续方便管理比如暂停游戏时遍历容器一次性清空所有子弹比在庞大节点树里找散落的子弹节点要舒服得多。3.2 玩家移动与鼠标瞄准实现Player脚本的核心是两件事移动和瞄准。移动到上面说过了用Input.get_vector()读取方向赋值给velocity再move_and_slide()。瞄准则用get_global_mouse_position()获取鼠标世界坐标然后用look_at()让角色朝向它。完整代码如下# player.gd extends CharacterBody2D export var move_speed: float 220.0 export var shoot_cooldown: float 0.15 export var bullet_scene: PackedScene onready var muzzle: Marker2D $Muzzle onready var sprite: Sprite2D $Sprite2D var can_shoot: bool true func _physics_process(_delta: float) - void: var direction : Input.get_vector(move_left, move_right, move_up, move_down) velocity direction * move_speed move_and_slide() # 鼠标瞄准角色整体朝鼠标位置旋转 look_at(get_global_mouse_position()) func _unhandled_input(event: InputEvent) - void: if event.is_action_pressed(shoot) and can_shoot: shoot() can_shoot false await get_tree().create_timer(shoot_cooldown).timeout can_shoot true func shoot() - void: var bullet: Area2D bullet_scene.instantiate() # 子弹从枪口位置射出方向为鼠标指向 get_parent().add_child(bullet) bullet.global_position muzzle.global_position bullet.set_direction((get_global_mouse_position() - muzzle.global_position).normalized())有一点要说明我故意把子弹加到Player的父节点而不是Player本身。因为如果加到Player下面子弹会跟随玩家移动实际表现就是弹道会带漂移——子弹明明应该在枪口射出后保持世界坐标不变结果因为父节点在动弹道变得歪歪扭扭。3.3 子弹与敌人生成逻辑子弹是个独立的Area2D场景。它不关心谁射出来的只关心朝哪个方向飞、碰到东西后做什么。脚本如下# bullet.gd extends Area2D export var bullet_speed: float 700.0 var direction : Vector2.RIGHT func _ready() - void: body_entered.connect(_on_body_entered) # 生命周期2秒后自动销毁防止弹道满天飞 await get_tree().create_timer(2.0).timeout queue_free() func set_direction(dir: Vector2) - void: direction dir func _physics_process(delta: float) - void: # 手写位移完全可控 global_position direction * bullet_speed * delta func _on_body_entered(body: Node2D) - void: if body.is_in_group(enemy): body.take_damage() queue_free()敌人的追逐逻辑更简单每帧朝玩家方向移动。这里有个细节要提一下如果敌人生成的一瞬间就朝玩家狂奔玩家在没有准备的情况下容易被围殴。我一般给敌人一个生成延迟前0.5秒只做“花屏预告”不做移动给玩家一个反应时间。完整的敌人脚本# enemy.gd extends CharacterBody2D export var enemy_speed: float 120.0 export var spawn_delay: float 0.5 var player: CharacterBody2D var ready_to_move : false func _ready() - void: player get_tree().get_first_node_in_group(player) await get_tree().create_timer(spawn_delay).timeout ready_to_move true func _physics_process(delta: float) - void: if not ready_to_move or not player: return var dir : (player.global_position - global_position).normalized() velocity dir * enemy_speed move_and_slide() func take_damage() - void: queue_free()这里省了很多东西比如敌人的血量、死亡特效、掉落但对于原型来说够用了。3.4 计分与游戏状态循环计分系统我用一个Autoload单例GameState来保存分数和游戏状态。为什么不用某个节点的变量因为多个场景都可能要读写分数全局单例最方便。# GameState.gd (Autoload) extends Node var score: int 0 func add_score(points: int) - void: score points print(Score: , score) func reset() - void: score 0然后在敌人死亡时调用GameState.add_score(1)在HUD脚本里监听分数变化并更新Label。游戏结束条件可以设为玩家被敌人碰到一定次数、或者玩家血量归零。原型阶段我直接做成“碰一下敌人就回到初始位置”简单直接连血量UI都省了。如果要加血量只需要给Player增加一个hit_count变量在碰撞回调里减一归零后显示Game Over界面并重载场景即可。4. 踩坑实录与排查技巧4.1 Godot中2D人物走路模糊的真相在搜索热词里出现过“godot中2D人物走路模糊”这个问题我在项目初期就遇到过表现是角色移动时视觉上会抖动、边缘发虚尤其在精灵边缘是锯齿状时特别明显。原因通常有三个排查顺序也可以按这个来第一是纹理过滤默认开了线性插值像素风的Sprite2D一旦被旋转到非90度整数倍角度边缘就会产生模糊。解决办法是给纹理设置TextureFilter为Nearest。第二是物理帧率与画面帧率不同步。Godot的物理更新默认是60Hz如果你的显示器是144Hz渲染帧和物理帧就会错位角色运动看起来一顿一顿的。这个情况可以用Engine.physics_ticks_per_second保持60不变同时在Project Settings里开启“Interpolation”让引擎自动插值渲染位置。第三是碰撞体的位置被设置为小数。虽然Godot支持float坐标但如果你的美术素材和碰撞体没对齐角色在地面上走起来会有肉眼可见的“点头”感。养成把Sprite2D和CollisionShape2D都设为整数坐标的好习惯能省掉一大半莫名抖动。4.2 子弹穿模连续碰撞和单帧跳变“子弹偶尔穿模”这个话题在2D physics社区里被反复讨论。本质原因是子弹每帧按速度移动如果帧率突然下降一帧的移动距离可能超过敌人碰撞体的厚度子弹就从敌人身上“跳过去”了。解决思路有几条按推荐程度排序限制子弹最大速度到一个合理值比如800像素/秒配合60帧时单帧移动约13像素大多数碰撞体直径都在20像素以上基本不会漏。如果一定要高速子弹改用move_and_collide()代替直接改position它会做连续碰撞检测能捕捉到移动路径上的碰撞。给子弹启用RigidBody2D并设置高的Continuous CDCCD但这么做会引入物理模拟的开销我一般只在对超高速子弹有强烈需求时才用。4.3 敌人扎堆、卡墙与屏幕抖动敌人扎堆是我做生成器时遇到的最常见问题。一开始用RigidBody2D做敌人它们之间会物理推挤结果刷出10个敌人后堆成一团相互卡位玩家站在远处就能无伤清怪。换成CharacterBody2D的move_and_slide()之后软体分离默认开启但还是会出现敌人朝同一个目标点移动导致“拥堵”。我的解决办法很朴素给每个敌人附加一个随机初始偏移速度让它们在一秒内逐渐收敛到正确的追踪方向这样至少不会一出场就叠在一起。卡墙的问题则需要注意生成位置。如果刷怪点正好在墙壁内部敌人每帧move_and_slide()会卡在墙里出不来。刷怪前检查一下生成坐标是否在墙壁的碰撞范围内如果被挡住就重新换一个点。屏幕抖动方面摄像机不要直接跟随玩家位置而是用平滑插值lerp或者开启Dragging模式否则角色极速移动时画面会像抽风一样狂抖。4.4 常见问题速查表用表格把最精华的结论列出来问题常见原因解决方案人物走路模糊纹理过滤或插值未开设置TextureFilter为Nearest开启Interpolation子弹穿模单帧位移太大限制速度、用move_and_collide或CCD敌人挤成一团用了物理体做敌人改用CharacterBody2Dmove_and_slide角色斜移更快方向向量未归一化用Input.get_vector()自动归一化子弹弹道漂移子弹挂在了移动玩家节点下改为加到场景根节点或子弹容器下偶尔卡住不动物理体卡碰撞体边缘调小CollisionShape或开启safe margin这些问题的共同点在于它们都不是“逻辑写错”而是引擎特性和游戏设计之间没有对齐。理解了原理排查起来会很快。5. 收尾一点经验之谈这个TopDownShooter项目我陆陆续续改了三版第一版能用但不好玩第二版手感好了但敌人逻辑太无趣第三版才真正做到“可以用来练手”。我个人最大的体会是做游戏手感永远是第一优先级而手感隐藏在这些数字里——移动速度、射击间隔、子弹飞行时间、敌人被击中后的反馈。与其一开始把系统铺得很大不如把最小循环打磨到“玩起来有点爽”再开始加内容。如果你也想复刻这个项目我建议你按最小可玩版本起步把移动、射击、敌人循环跑通后先给别人试玩亲眼看看他们操作时的真实反应。你会吃惊地发现很多自己觉得合理的参数别人玩起来根本不是那么回事。另外像素风素材不用自己画网上有很多免费CC0素材先拿来用等玩法定型后再替换成自己的美术资源不会浪费时间。本文还有配套的精品资源点击获取
返回列表