简介:一份基于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物理层(layer,mask)。我给项目规划了四层:玩家、子弹、敌人、墙壁。子弹的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 CD(CCD),但这么做会引入物理模拟的开销,我一般只在对超高速子弹有强烈需求时才用。
4.3 敌人扎堆、卡墙与屏幕抖动
敌人扎堆是我做生成器时遇到的最常见问题。一开始用RigidBody2D做敌人,它们之间会物理推挤,结果刷出10个敌人后堆成一团,相互卡位,玩家站在远处就能无伤清怪。换成CharacterBody2D的move_and_slide()之后,软体分离默认开启,但还是会出现敌人朝同一个目标点移动导致“拥堵”。我的解决办法很朴素:给每个敌人附加一个随机初始偏移速度,让它们在一秒内逐渐收敛到正确的追踪方向,这样至少不会一出场就叠在一起。
卡墙的问题则需要注意生成位置。如果刷怪点正好在墙壁内部,敌人每帧move_and_slide()会卡在墙里出不来。刷怪前检查一下生成坐标是否在墙壁的碰撞范围内,如果被挡住就重新换一个点。屏幕抖动方面,摄像机不要直接跟随玩家位置,而是用平滑插值(lerp)或者开启Dragging模式,否则角色极速移动时画面会像抽风一样狂抖。
4.4 常见问题速查表
用表格把最精华的结论列出来:
| 问题 | 常见原因 | 解决方案 |
|---|---|---|
| 人物走路模糊 | 纹理过滤或插值未开 | 设置TextureFilter为Nearest,开启Interpolation |
| 子弹穿模 | 单帧位移太大 | 限制速度、用move_and_collide或CCD |
| 敌人挤成一团 | 用了物理体做敌人 | 改用CharacterBody2D+move_and_slide |
| 角色斜移更快 | 方向向量未归一化 | 用Input.get_vector()自动归一化 |
| 子弹弹道漂移 | 子弹挂在了移动玩家节点下 | 改为加到场景根节点或子弹容器下 |
| 偶尔卡住不动 | 物理体卡碰撞体边缘 | 调小CollisionShape,或开启safe margin |
这些问题的共同点在于,它们都不是“逻辑写错”,而是引擎特性和游戏设计之间没有对齐。理解了原理,排查起来会很快。
5. 收尾:一点经验之谈
这个TopDownShooter项目我陆陆续续改了三版,第一版能用但不好玩,第二版手感好了但敌人逻辑太无趣,第三版才真正做到“可以用来练手”。我个人最大的体会是:做游戏,手感永远是第一优先级,而手感隐藏在这些数字里——移动速度、射击间隔、子弹飞行时间、敌人被击中后的反馈。与其一开始把系统铺得很大,不如把最小循环打磨到“玩起来有点爽”,再开始加内容。
如果你也想复刻这个项目,我建议你按最小可玩版本起步,把移动、射击、敌人循环跑通后,先给别人试玩,亲眼看看他们操作时的真实反应。你会吃惊地发现,很多自己觉得合理的参数,别人玩起来根本不是那么回事。另外,像素风素材不用自己画,网上有很多免费CC0素材,先拿来用,等玩法定型后再替换成自己的美术资源,不会浪费时间。
本文还有配套的精品资源,点击获取