
简介一个使用 C 开发的简易俯视角 2D 射击游戏基于 SFML 与 Box2D 实现适合想了解游戏循环、碰撞响应、光照阴影渲染的开发者学习。项目源码完整且可构建共 76 个文件主体为 h/cpp 头文件与实现源码另含 png 精灵图、pgm/ppm 地图与光照数据、GLSL 着色器、xcf/svg 设计源文件以及 Makefile压缩包仅 140KB源码目录清晰分为 headers、src、shaders、sprites、maps 等模块便于按功能查阅。具体而言代码覆盖了玩家与敌人控制、弹幕管理、视野阴影、多光源光照、小地图、生命条、可碰撞物体以及基于高度图的地图生成并演示了如何将 Box2D 物理引擎与 SFML 渲染结合frag 着色器文件可帮助理解多重光照和法线旋转效果整体代码是学习 2D 游戏项目结构的上佳范本。附带的 Linux 构建说明与 Windows 二进制使读者能快速跑通并对照调试。已有 365 人学习适合具备 C 基础、希望快速上手 2D 游戏开发的中级读者。 最近在社区里看到好几个刚入门做游戏的朋友不约而同问起同一个问题2D游戏要做8向动画帧吗紧接着又有人问为什么自己做的2D人物走路总发糊、边缘带毛刺。这两个问题看起来是动画和渲染的细节其实都能在一个很经典的小项目里找到答案——TopDownShooter一个简单的自上而下2D射击游戏。这个项目类型听起来挺基础但它是把“输入控制、角色移动、投射物、碰撞检测、敌人AI、波次生成、画面表现”这些游戏开发里的核心零件串起来的最短路径。我自己当年就是从这样一个小项目开始才真正搞懂了游戏循环和引擎节点系统之间的关系而不是停留在“看教程、跟着敲、然后忘”的循环里。这篇文章以Godot引擎为例把TopDownShooter从场景树搭建、8向移动、子弹碰撞到画面模糊排查的完整过程走一遍。你如果正在学某个2D游戏引擎或者准备做自己的第一个完整游戏这篇文章可以当一份直接照着抄的作业。1. 为什么拿“自上而下射击”当练手项目最划算先说结论TopDownShooter这个类型几乎是所有2D游戏类型里“投入产出比”最高的起步项目。一个最简单的版本只需要一套输入映射、一个会移动的玩家节点、一个会按方向飞出去的子弹、几个会朝玩家靠拢的敌人就够了。这些东西加在一起就是一个能玩的游戏循环。对比一下其他类型你就明白了。平台跳跃游戏虽然看起来简单但跳跃手感、重力参数、平台边缘判定、摄像机跟随——这些细节每一项都能折磨你一周。2D RPG则需要先设计对话系统、背包系统、任务系统这些系统每一个都是无底洞。而自上而下射击游戏把核心玩法聚焦在“移动瞄准射击”这个三角形上你不需要处理重力不需要做二段跳不需要设计复杂的关卡结构。你要做的就是让角色跟着输入走让子弹朝目标飞让敌人有基本的行为逻辑。还有一个重要的原因这个项目天然适合“分阶段完成”。你完全可以先用方块的占位符搭出全部玩法再逐步替换成正式美术资源。换句话说这个项目的核心价值不在美术而在“游戏逻辑的结构”。你用它学会的是怎么组织代码、怎么划分节点、怎么让各个系统不打架这些能力迁移到任何游戏类型上都通用。有人可能会问那用Unity还是Godot我的看法是现阶段学Godot更省心一点因为它内置的节点体系对2D游戏非常友好比如CharacterBody2D、Area2D这些现成的节点直接就把物理检测和移动逻辑帮你包好了。Unity的2D物理和Animator做起来当然也可以但多了一套概念要学。至于H5 2D游戏引擎比如Phaser这类适合你做网页小游戏分发但就“理解游戏运行原理”这件事来说还是本地引擎的调试体验更舒服。选好了引擎接下来最关键的步骤不是写代码而是把场景树的结构想清楚。很多人项目做到一半改起来痛苦就是因为一开始节点挂错了父级或者碰撞层级没有规划。2. 场景树这样搭后面加功能才不返工在Godot里搭TopDownShooter的场景树说到底就一句话把“世界里能看到的东西”和“管理世界运转的逻辑”分开挂。不要让管理逻辑的节点显示在屏幕上也不要在乱七八糟的位置去找某个角色节点。我常用的结构是这样一个主场景MainNode2DWorldNode2DPlayerCharacterBody2DEnemyContainerNode2DBulletContainerNode2DUICanvasLayerHUD包含血量、分数、波次提示SpawnManagerNodeGameManagerNodePlayer、EnemyContainer、BulletContainer都挂在World下面是因为它们的坐标天然是同一套世界坐标。子弹打出来、敌人跑过来所有距离计算都基于同一个父坐标空间省去转换麻烦。EnemyContainer和BulletContainer这种“容器节点”看着不起眼但他们能让你在遍历所有敌人或所有子弹时特别方便——直接get_children()就全部拿到了。而SpawnManager和GameManager作为纯逻辑节点不继承Node2D因为不需要出现在世界里。有经验的开发者还会再做一步把玩家和敌人的碰撞层分开设置。Godot的碰撞层是位掩码默认全在Layer 1很多人图省事不设置结果子弹打到自己、敌人互相推挤、玩家踩着敌人走路——全乱套。我习惯的分层方式是这样的碰撞层对象说明Layer 1玩家主要接收敌人近战伤害Layer 2敌人接收玩家子弹伤害Layer 3玩家子弹只检测敌人Layer 4敌人子弹只检测玩家Layer 5墙壁/障碍物子弹和角色都检测每一层只检测指定目标比如玩家的Collision Mask只勾选Layer 4敌人子弹和Layer 5墙壁不勾选Layer 2敌人这样玩家和敌人就能互相穿过不产生物理挤推。这个小细节能让后续的物理逻辑清晰非常非常多。如果一开始就把Mask全勾上后面排查“为什么子弹没打中为什么角色被卡住”会非常痛苦。场景树确定之后下一步就是最核心的手感部分移动。3. 移动和动画方向先跑通8向逻辑再谈8向帧3.1 用Input Vector做8向移动Godot里做8向移动最直接的方式是读取Input.get_vector()。这个方法会帮你把输入映射成一个二维向量比如按下W和D得到的值就是Vector2(1, -1)面板朝右上方向移动。这一步逻辑很简单但有一个参数最容易忽略deadzone。摇杆类输入如果不设deadzone角色待机时会因为摇杆微小的偏移而抽搐键盘输入则无所谓。我在项目里通常把deadzone设为0.2省心。移动速度我建议用常量统一管理不要在多个脚本里各写各的。做一个PlayerStats的常量类或者直接在Player脚本顶部定义const SPEED 240.0。至于为什么是240因为我的测试地图是 320x180 的逻辑分辨率240像素/秒能让角色在三秒内横穿屏幕体感上比较适中。如果地图更大速度要相应调高判断标准就是“玩家和敌人之间的距离变化是否让人感觉紧张又可控”。3.2 射击方向怎么办射击方向的处理思路和移动不同。移动是“输入向量直接映射”射击则要区分两种瞄准方式八方向锁定和鼠标瞄准。八方向锁定最简单玩家按方向键朝固定八个方向之一射击。鼠标瞄准则是计算鼠标位置和玩家位置的角度用global_position.direction_to(get_global_mouse_position())得到方向向量。对于PC端的TopDownShooter我强烈推荐鼠标瞄准因为鼠标的精度远超方向键玩起来痛快得多。移动用WASD瞄准用鼠标这套方案是这种类型的标配。有了方向向量就能得到旋转角度angle vector.angle()。然后把这个角度赋给枪口节点或整个玩家Sprite的rotation。这里有个常见问题如果你的美术素材本身是朝右画的那rotation0时正好朝右但素材如果是朝上画的那必须做rotation vector.angle() PI/2之类的修正。这个修正最好写成一个辅助函数比如get_aim_angle(),统一返回修正后的角度免得在多个地方重复加PI/2。3.3 到底要不要做8向动画帧回到社区里那个高频问题2D游戏要做8向动画帧么看情况。如果你做的是像素风小游戏角色走路方向只有上下左右四个方向那四向帧就够了因为斜向移动可以用上下左右帧的过渡来伪装虽然细看有点别扭但大多数玩家不会盯着看。如果你做的是偏精致的独立游戏角色动作幅度纤细四向会显得很僵硬那八向是值得的。而如果你用的是Live2D或Skeleton这类骨骼动画工具那根本不存在“画8向帧”的问题直接靠骨骼旋转去控制。对于TopDownShooter这个项目我建议先不要做任何动画帧直接用一张小方块当占位符或者给Sprite挂一个简单的Swing效果。先把射击手感和逻辑调通了再回来补动画。因为动画帧和逻辑耦合在一起的调试体验非常痛苦你分不清角色动作僵硬是因为逻辑卡了还是因为动画没切对。用占位符跑通逻辑动画纯粹是表现层这样定位问题快得多。等逻辑稳定了再考虑用AnimationTree做动画方向融合。Godot的AnimationTree里有一个BlendSpace2D节点你可以把Idle和Run动画放进去用移动方向作blend_position这样角色朝哪个方向就自动播放对应方向的动画过渡还很平滑。这个做法的好处是你不需要为每个方向手动写条件分支方向是连续值动画过渡自然。4. 子弹飞行与2D物理碰撞好手感是“分层”调出来的4.1 子弹用Area2D而不是CharacterBody2D子弹节点我通常用Area2D不会用CharacterBody2D或RigidBody2D。很多人看到“子弹会飞”就下意识想用刚体但这是TopDownShooter里最典型的认知误区。CharacterBody2D和RigidBody2D都带“物理实体”属性会参与碰撞响应两个物理实体相撞会互相推挤而子弹这种“碰到东西就该发挥作用然后消失”的对象根本不需要物理推挤只需要“检测到碰撞”。Area2D的优势就是它只管感知不管物理响应。你让它飞它只是按你的代码改变位置你和敌人碰撞它也只会发出信号不会推挤敌人。这个特性让子弹的行为非常可控。子弹脚本的核心逻辑就这么几行extends Area2D var velocity: Vector2 var damage : 1 const SPEED : 600.0 func _physics_process(delta): position velocity * delta func _on_body_entered(body): if body.is_in_group(enemies): body.take_damage(damage) queue_free()发射时玩家生成子弹实例设置velocity为瞄准方向乘SPEED。velocity要记得归一化否则斜向射击时子弹会飞得比横向快这个问题在2D游戏里太常见了。Godot的Vector2.NORMALIZED或者direction_to()返回的值本身已经归一化了但如果自己算方向再乘速度一定要检查长度。4.2 高速子弹别忘Continuous CD子弹以600像素/秒飞行时每帧按60FPS算要移动10像素。如果你的子弹或碰撞体足够小而敌人的碰撞体也不大完全可能一帧之内子弹穿过了敌人但没触发碰撞这就是所谓的“隧穿效应”。RigidBody2D有Continuous CDContinuous Collision Detection选项可以缓解这个问题但Area2D没有这个属性。所以我自己更常用的方案是子弹体积稍微做大一点或者用physics_interpolation配合更短的物理步长。但最省事的办法其实是——放大子弹的CollisionShape。子弹的碰撞体比视觉稍微大一点点完全不违和却能让命中判定可靠得多。视觉大小和碰撞大小本来就是两回事这不算作弊这是手感调校的常规操作。4.3 射击手感是分层调出来的很多人调射击手感只看“有没有打中敌人”这是远远不够的。一个射击游戏的手感至少由三层构成。第一层是发射逻辑。射速、弹速、连发/单发、散射/爆破这些都影响开枪瞬间的反馈。TopDownShooter里最基础的指标是射速也就是两次射击之间的冷却时间。我一般把冷却设成0.15到0.25秒手感差别很微妙——0.3秒以上会感觉“卡壳”0.1秒以下会感觉“失控”。具体数值要配合你的音效和动画来微调没有绝对标准。第二层是命中反馈。敌人被击中时的闪烁、击退、碎尸或消失动画以及命中时子弹和敌人的位置是否精确对齐。我强烈建议在敌人脚本里加一个take_damage()方法统一处理掉血和闪烁而不是让子弹直接修改敌人的hp值。这个抽象能让你后续加暴击、加护甲、加击杀特效都方便得多。第三层是屏幕表现。震动、拖影、Hit-Stop命中时游戏暂停几帧放大打击感这些是很多独立游戏和街机游戏爱用的技巧。Godot里做Hit-Stop最简单的方式是get_tree().paused true再配合Timer恢复或者更轻量地把场景里所有节点的速度系数临时设低。不过对新手来说有个忠告画面表现一定要在核心逻辑稳定之后再加千万不要一上来就搞震动、闪光、粒子。这些效果会让你的调试过程变得很痛苦因为你分不清“手感差”是因为逻辑慢还是因为这些表现遮住了反馈。子弹系统跑通之后玩家的体验闭环就形成了能走、能开枪。但一个游戏要有“玩头”必须有敌人来让你开枪。5. 敌人AI和波次生成用最少代码让游戏“活”起来5.1 简单AI也分三六九等TopDownShooter的敌人AI可以非常简单但再简单的AI也要分类型来设计不能让所有敌人千篇一律地朝玩家走。最常见的基础敌人是“追踪者”行为逻辑就一句话func _physics_process(delta): var direction global_position.direction_to(player.global_position) position direction * speed * delta这种敌人适合当杂兵数量多让玩家享受割草的爽快感。但如果你全场只用这一种敌人游戏很快就会乏味。我会加第二种“远程敌人”它和玩家保持一段距离在一个范围内游走每隔几秒发射一颗子弹。这个AI的实现思路是计算距离如果距离太远就靠近距离太近就后退在合适距离停下来射击。代码量不大但整个攻防的节奏感立刻不一样了。还有一点需要注意敌人不要单个实现AI然后打天下而是把公共逻辑抽到一个BaseEnemy脚本里远程近程炮台都继承它。这样你后续给敌人加血条、加掉落物、加状态效果只需要改基类所有敌人同步更新。5.2 用数据和规则驱动波次波次生成最忌讳的就是把每一波内容硬编码在代码里。第一波三个敌人第二波五个敌人——如果你这样写后面加波次、调难度会改到你怀疑人生。更好的做法是把波次配置做成Resource或JSON数据写一个SpawnManager统一读取。一个简单的配置文件可能是这个样子的[ {type: grunt, count: 3, spawn_interval: 1.0}, {type: grunt, count: 5, spawn_interval: 0.8}, {type: ranged, count: 2, spawn_interval: 1.5} ]SpawnManager读取这个列表用Timer按spawn_interval间隔生成敌人计数满后进入下一波。每波结束后给玩家一秒钟喘息时间同时显示“Wave X”的提示。这个结构虽然简单但后续扩展空间很大你可以给每波加enemy_hp_modifier、enemy_speed_modifier甚至指定不同的生成位置方案。生成位置的逻辑也要注意不要在所有敌人都在屏幕边缘刷出来那样玩家只能站在中心挨打。更好的方案是“屏幕边缘随机远离玩家最近距离”也就是生成点在玩家视线边缘之外但又不会离玩家太远导致玩家还要跑路去找。一个很简单的公式从玩家位置出发选一个随机角度取玩家周围300到600像素之间的距离作为生成点。这样游戏节奏始终是“来一波打一波”的循环玩家永远有事做。5.3 敌人数量的上限控制这个坑是我当初踩得最深的波次无限堆最后屏幕上几百个敌人帧率直线下降玩家根本没地方走。没有上限的敌人生成再好的优化也没用。我的解决方案是SpawnManager里增加一个逻辑判断只有当EnemyContainer的子节点数量低于某个阈值时才继续生成。边打边补保证屏幕上最多同时出现15到20个敌人。这个数字可以根据实际机器的性能来调但20个敌人在TopDownShooter里已经完全足够营造“满屏危机”的感觉了。用这个方案的好处是玩家杀得越快新敌人补得越快不会出现“杀完一波等下一波”的断档节奏更饱满。敌人和波次都有了画面的观感问题这时候就藏不住了。很多项目玩起来其实不差但录屏发到网上就是各种糊、各种闪浏览量惨淡——这一步就能排查掉大半。6. 像素画面发糊掉帧三个排查方向一次说清6.1 先看看是不是纹理过滤在“和稀泥”你在社区里搜“godot中2d人物走路模糊”十有八九搜到的是Godot论坛里的老帖。这个问题几乎都是同一个原因Godot默认对纹理启用了线性过滤Linear Filter像素画在放大或缩小时会被GPU插值计算相邻像素之间的颜色被“糊”在一起看起来就像蒙了一层雾气。解决方法是把项目的纹理过滤改成最近邻Nearest。在Godot 4里路径是Project Settings → Rendering → Textures → Default Texture Filter把它从Linear改成Nearest。如果你的游戏是像素风这一步是必须的不做的话后面所有画面调校都白搭。改了之后像素画的边缘会变得锐利、干净不再雾蒙蒙。6.2 光栅滤波器和“像素抖动”其实是同一回事热搜词里那个“2d光栅滤波器”看着高深说白了就是像素图在移动时产生的规则性闪烁或抖动老玩家会叫它“爬格”或“闪烁边”。Godot里只要把Project Settings里的“Snap 2D Transforms to Pixel”打开就可以保证精灵每次移动都正好落在整数像素坐标上不产生半像素渲染。开启之后你会发现角色移动平滑度没有肉眼可见的损失但是画面稳定感明显提升。另外如果你的场景里有多个摄像机或缩放节点也要检查一下Camera2D的锚点和缩放是否是整数倍。半整数倍的缩放是“模糊闪烁”的高发区我在项目里直接把Base Zoom设成了2让320x180的逻辑分辨率正好放大到640x360的窗口一像素对应屏幕上的两像素干净利落。6.3 动画切换冲突导致“鬼畜”最后一种“模糊”其实是动画问题人物看起来像是抽风走路姿势一闪一闪。这不是渲染问题是动画状态机切换错了。尤其是你按我前文说的用了AnimationTree的BlendSpace2D后如果Idle和Run的动画没做过渡或blend_position的更新频率比动画的播放频率还高就会导致角色不停在两个动画之间来回跳。解决方式是给BlendSpace2D的每个动画刀点设置过渡时间以及在玩家脚本里除了_physics_process中更新移动逻辑外要在同一帧里统一更新动画的blend_position避免移动和动画在两个物理帧不同步。这个“同步更新”的要点是不要在_process里更新动画要在_physics_process的末尾等所有移动逻辑算完后一次性把参数推给AnimationTree。这样能最大限度减少状态不同步。在经历了这三个方向的排查之后那个最初看起来玄乎的“人物走路模糊”问题其实都落在纹理过滤、像素对齐、动画同步这三个可控点里。单独看每一个都不难难的是把它们按顺序排查清楚。TopDownShooter这个项目做到这个程度麻雀虽小五脏俱全。你不用急着给它加更多花哨的系统先把现有代码重构一遍把散落的常量统一起来把敌人的AI基类整理干净你会在下一个、再下一个项目里持续吃到这次整理的红利。我自己就是在这个项目之后才真正看懂了“场景树即架构”这句话的含义。你先把它跑通再去嫌弃它简单那时候你的水平就已经往前走了一大截了。本文还有配套的精品资源点击获取