《从零开始的Godot游戏开发——冥日芳粥:肿么滴》项目总结
前言
其实这个项目老早就开始了,但是做到一半因为考试、课程设计等拖了很久,到暑假也是沉浸在游戏与短视频的海洋中无法自拔。开学后本来想学UE,但是突然记起大V老师的这个项目还没完结,于是想着至少把这个做完吧,而且大V老师的课可以学到很多设计思路,应该也不算是浪费时间。总之最后花了一周完结了这个项目,顺便加上了虚拟摇杆,能导出到手机上愉快地玩耍了。
项目简介
该项目为横版2D射击游戏,neta星露谷中的酒馆游戏,玩家操纵可八方向移动的角色进行八方向射击,击败不断生成的敌人,拾取敌人掉落的道具强化自己,坚持倒计时结束即可胜利。
项目结构
项目分为:
- 游戏主场景:包括地图、空气墙、角色实例、敌人出生点、敌人容器、刷怪计时器、UI小组件、摇杆实例、音频播放器。
- 玩家场景:包括玩家动画(双形态)、碰撞箱、射击计时器、三类buff计时器、音频播放器。
- 敌人场景:包括敌人动画、碰撞箱、碰撞伤害检测区域、爆炸伤害检测区域、音频播放器。
- 子弹场景:包括子弹纹理与碰撞体积。
- 掉落物场景:包括掉落物纹理、碰撞体积和剩余时间计时器。
- 虚拟摇杆场景:包括一个移动虚拟摇杆和一个射击虚拟摇杆。
常用 Godot 节点
TileMapLayer:其中有两个节点TileMap和TileSet。TileMap是用于在二维网格上摆放瓦片,绘制地图的节点;TileSet是TileMap要使用的瓦片资源库,可以存放资源图片,给瓦片附加导航、碰撞、遮挡、动画等额外信息。Camera2D:玩家最终看到的取景框。Node2D:纯粹的 Transform 节点,描述旋转、缩放、坐标等位置信息,本身不具备模拟移动或碰撞处理能力。CharacterBody2D:继承自Node2D,天生拥有一套对于移动、碰撞和滑动处理很完善的能力。Sprite2D:用于存放、渲染图片纹理。RigidBody2D:继承自Node2D,偏向纯粹受物理模拟去运动的物体,一般完全托管给物理引擎。CollisionShape2D:为父级提供Shape2D的节点。AnimationSprite2D:最简单的生成动画的节点。StaticBody2D:无法被外力移动的2D物理物体,手动移动时不会影响路径上的其他物体。Area2D:是2D空间的一个区域,能够检测其他碰撞对象的进入或退出,本身不参与物理模拟。Timer:计时器节点。Mark2D:用于2D位置提示,用于编辑,本身不参与碰撞等物理逻辑,很适合作为出生点、目标点、路径点或者摄像机的参考位点等只需要位置的辅助对象。AcceptDialog:默认用途是允许它只被接受或关闭,结果相同。但是,confirmed和canceled信号允许使两个动作不同,add_button()方法允许添加自定义按钮和动作。AudioContainer:能够播放音频流,播放的效果与位置无关,常用于用户界面、菜单、背景音乐。
Godot 函数
_physics_process():背后有自己的刷新计时器,默认60次/s,用来处理角色移动、速度计算和碰撞这些与物理世界强相关的内容。_process():屏幕每刷新一帧,就调用一次,适合处理偏视觉表现的内容,如界面变化,镜头效果等不太依赖物理精度的逻辑。move_and_slide():CharacterBody2D内部函数,用于把速度赋给角色进行移动。_ready():节点就绪时调用,即节点场景树及其场景以及子节点本身被实例化到其他场景后才会调用。clampf(value, min, max):钳制 value,返回不小于 min 且不大于 max 的 float。lerpf(from, to, weight):通过 weight 中定义的因子在两个值之间进行线性插值,weight 应介于 0.0 和 1.0 之间(包含)。wrapf(value, min, max):将 value 包裹在[min, max)区间内,超出范围就循环回绕。get_tree():返回当前节点所在的SceneTree对象。add_child():把当前在内存中的对象添加为当前场景树的子节点。
出现的问题
其实大部分的问题都是因为对于Godot内置函数的作用不了解产生的,除了上述类型的问题简单整理了一下:
- 在刚刚实现玩家射击和两种模式的时候,运行发现直接就是强化形态,并且螺旋弹幕也没有发射出来。
- 第一个bug是因为开头设置错了模式,把普通形态写成强化形态了(这波啊,这波是自动补全的问题[doge]);
- 而子弹不发射的原因是我忘记设置
Timer的one_shot了,并在_ready函数中开启了计时器,这样就导致计时器一开始就启动了,并且一直在循环,而_try_shoot函数检测到倒计时还没结束就返回 false 不生成子弹。所以设置one_shot就解决问题了。
- 道具临近消失时一直不闪烁。
- 原理是可以通过
set_shader_parameter方法更改这个材质的着色器中某个为 uniform 的值,而且参数名称必须完全匹配。我打错名字了所以一直没起作用。本来已经用了静态变量来避免后续引用出错,结果定义静态变量时就写错了,这就尴尬了,下次一定先看好在写。
- 原理是可以通过
- buff计时器。
- 这里我没有用大V老师的手动计算时间逻辑,而是尝试使用 Godot 中
Timer节点的功能去实现。但是一开始我写完后发现玩家拾取道具后 buff 不会消失,检查后发现原来又忘记设置one_shot了(尬笑)。加上后为确保深层逻辑没有问题去问了一下AI,结果发现这样会导致_refresh_shooting_timer_wait_time()每帧都在刷新,产生大量无用计算,AI建议我再加一个标志位来标记 Buff 是否 start 过,但我觉得太麻烦了。查阅Timer节点资料后发现还有一个timeout方法完美适配,无需每帧检测,直接让 timer 到期时通知我,连接上信号后让他自己处理结束后的逻辑。简直太丸美辣!
- 这里我没有用大V老师的手动计算时间逻辑,而是尝试使用 Godot 中
- 虚拟摇杆。
- 这部分大V老师没有讲,我去看了一下 timothyqiu 老师的勇者传说里虚拟摇杆的制作,照葫芦画瓢做的。然而因为项目结构的不同,在处理点击位置在各个坐标系之间的变换时,出现了点击位置与画面位置不符的问题,最终也没有细致的解决,只能UI节点下给每个摇杆都设置一个
Control父节点,并通过 Godot 内置的坐标系变换函数来处理位置变换,无法做到向 timothyqiu 老师那样使用矩阵变换的方式处理。总之最后算是做好了,之后的优化等有机会深入学习之后再进行吧。
- 这部分大V老师没有讲,我去看了一下 timothyqiu 老师的勇者传说里虚拟摇杆的制作,照葫芦画瓢做的。然而因为项目结构的不同,在处理点击位置在各个坐标系之间的变换时,出现了点击位置与画面位置不符的问题,最终也没有细致的解决,只能UI节点下给每个摇杆都设置一个
其实还有一些问题和bug,但是我忘记了他们产生的原因了,也没复现出来。就比如本来玩家碰到敌人时会扣血但不会闪烁,我记得是在某个条件判断中忘记加 = 了,但是怎么也记不起来在哪,看来以后出现bug要及时记录原因和解决方法,以免下次再犯。
代码思路架构与技巧
- 静态类型的声明:在一些需要频繁使用、需要保持不变的常量建议使用静态类型声明,便于编辑器自动帮我们发现问题。
- 移动朝向控制动画:当使用移动朝向的向量作为判断动画朝向的标准时,建议加 if 条件判断,当向量不是 0 时才会更新动画朝向。否则有可能出现抬起按键角色动画直接恢复默认朝向的情况。
- 二维向量映射为移动动画:先计算哪个轴的移动分量更大,就优先使用哪个轴的动画。
- 面向对象思想:把不同的对象各自封装成一个独立的类,让其自己维护自己的更新和销毁等生命周期行为。创建独立的脚本,各自管理自己的状态,对外只提供简单的接口,即函数,功能由自己决定。
- 标记—清除策略:先对需要从内存中删除的对象进行标记,在这一帧结束之后合适的时候再进行实际的删除工作。一方面能避免函数在一帧之内调用时序的问题,访问了已被删除的对象而产生底层的内存错误;另一方面,方便引擎自己决定实际释放对象内存的时间,从而避免同一帧中同时释放大量对象内存造成卡顿,可以分帧执行内存回收任务。
- 射击游戏子弹的基本逻辑链:读取输入并调度射击分支 -> 检查冷却并准备发射 -> 决定本轮发射几颗子弹 -> 实例化子弹并设置出生位置 -> 子弹自行飞行、命中、超时回收。
- 兜底策略的必要性:工程上的经验,就是考虑使用什么样子的机制去提前预防人为的失误,以及在人为失误出现后,如何让整个系统尽可能保持正常的运作下去。
- 在进行浮点数的比较时,因为精度的问题,用近似相等的判断比直接相等要好一些。
- 伤害间隔:当设计敌人对玩家的碰撞伤害等类似的伤害时,不要忘记加入伤害间隔,否则可能会变成“帧伤”一瞬间清空玩家血条。
- 性能优化:在游戏开始时扫描场景树和配置资源这些内容并将其缓存,避免在游戏更新过程中频繁扫描。
- 进行如查找剩余敌人总数的功能封装时,最好先检查一些类型是否为所要找的节点。
- 射线检测:因为玩家和子弹的生成位置存在偏移,所以会出现子弹生成的位置在物理世界碰撞箱内,从而导致视觉上玩家枪口被堵住但是子弹却发射出去了的情况。这时通过射线检测,判断玩家中心位置和子弹直接有没有障碍物阻挡。若有则返回 false 提醒上层不应生成子弹。
设计思路以及其他知识&技巧
- 纹理过滤:当图片原始尺寸和显示尺寸不一致时,就需要重新采样。常见策略主要有两种,一种是临近过滤,另一种是线性过滤。临近过滤是这个位置该显示什么颜色,就去把离它最近的那个颜色拿来使用,所以边缘利落,适合像素风格;而线性过滤会参考周围几个像素,把颜色做一个平滑结合,所以看起来更顺,但像素边缘会变得很糊,一般用于非像素的游戏素材。
- 节点顺序:节点的顺序没有特别的谁先谁后的区别,一般来说会把角色动画放在角色碰撞外形之下,方便查看碰撞外形的大小并调整。
- 命名:在进行对游戏资产的命名时,注意要使用规范的有规律的命名,这样才能在代码中进行更简单更优雅的方式进行控制。
- 动画:对于玩家角色的互斥状态(如生和死),不会同时显示的动画,可以直接设置为一个动画节点下的不同动画状态,切换起来更简单不易出错。而可以同时显示的状态,则建议分为两个不同的动画节点,只需切换可见性。
- 在创建一个场景前,先进行需求分析,考虑这个场景属性是什么,要做什么,和谁交互,交互结果是什么等等,先分析后实现往往会事半功倍。
- 子弹生成时应与玩家有一定的偏移距离,在枪口位置处生成。
- 改变动画朝向时,射击的方向应优先于移动的方向。
- 物理层设计思路:分析世界里各类物品之间的物理交互,拆分层级。物理层的划分不是越多越好,最好尽早想清楚哪些碰撞关系是必须划分到不同的物理层中。记得给物理层取名。大体思路:列出游戏中有哪些“对象身份”,如玩家、敌人、障碍物等 -> 列出他们之间需要发生哪些关系 -> 把明显不同职责的碰撞拆开,如敌人身体和敌人的伤害检测区域。
- 资源驱动配置:用 Scene 负责怎么表现怎么交互这种大家都相同的逻辑,而 Resource 负责具体什么效果、数值强度、持续时间等差异化的部分,方便后续拓展。
- 设计 Buff 效果的配置字段的时候,考虑 Buff 会涉及几个不同的数据维度,后续想拓展不同的 Buff,就对这几个维度进行排列组合和不同强度的设置就行。
- 着色器:此项目主要用其来做闪烁效果,通过控制图片的透明度来实现。应当闪烁时通过引入
TIME,截取其与闪烁速度相乘后的小数部分,若其大小小于透明时间占比,则设置透明度为完全透明,否则则完全显示。 - 道具系统:主要分为五个部分:道具配置脚本、道具配置资源、图集资源、道具场景和玩家角色。图集资源切分了游戏素材,作为了道具配置资源的一部分,设置到了不同的道具配置资源中;道具配置资源有哪些可配置的项目,是由我们的道具配置脚本来定义的;而道具场景只是充当了传递道具配置数据的作用;当玩家触碰到道具时,就直接把道具的配置资源丢给玩家的
apply_pickup方法来执行实际的 Buff 生效逻辑。 - 控制边界条件:控制边界条件的思维习惯是很有必要的,用于避免极端情况出现。
总结
这个项目的难度其实不高,但是有很多思想和细节需要把控和注意,串联了从游戏主场景到玩家、子弹、道具和敌人直接的逻辑,对游戏的基本架构有了深刻的理解。总体来说是非常值得一做的项目,在这里再次感谢大V老师:Voidmatrix,其实我也算是老学员了,从老师的《从零开始的C++游戏教程》一路跟到这里。老师的教程非常好,十分建议想接触游戏的同学观看学习。