Flutter游戏开发 初探Bonfire

新的工具包 “Flutter Casual Games Toolkit “已随Flutter 3.0公布。它并不假装是任何问题的通用解决方案,但我们看到它是如何向游戏开发迈出 “官方 “的第一步。

还有其他工具,其中最强大的是Flame框架。在Flame的基础上建立了 “Bonfire”–一个游戏开发框架。它看起来至少是开发RPG游戏的最简单的框架。

在这篇文章中,我想分享我对Bonfire的经验,并展示它的优点和缺点……如果你还不确定在你的第一个游戏项目中使用什么框架,我希望我的文章会对你有所帮助。

第一印象

让我们来总结一下框架的主要功能,在简短的列表中。

  • 现成的控制层:支持键盘和虚拟游戏板。
  • 实体 “玩家”、”敌人”、”追随者 “都可以使用。
  • 用于创建 “飞行攻击物体 “的实体,如子弹。近战攻击机制。
  • 实现NPC向玩家移动并攻击他的混合器和函数。
  • 为玩家的角色在屏幕上用鼠标点击的点进行寻路。
  • 支持平铺地图编辑器、动画平铺、与平铺的碰撞、读取平铺属性。将Tile放在不同的高度上。
  • 将Tile中定义的对象映射到Dart的类中,以实现自定义行为。
  • 照明,全屏色彩效果。
  • 平滑的相机移动。
  • 用户界面,对话框。
  • 迷你地图。
  • …也许还有一些我漏掉的东西,或者在我准备这篇文章的时候已经实现了。

这个工具箱看起来非常酷,对吗?
真的,这个框架允许我们在Tiled中建立大部分的游戏,使用对象的属性设置所有需要的参数,然后在你的代码中读取它们,并通过继承预定义的实体在自定义类中实现缺失的游戏逻辑。

在建立一个项目后,一切都非常简洁明了:我们有一个游戏对象,对象有回调来接受伤害,处理与其他对象的碰撞,运动时调用函数等等。你什么都有了,只要去实现你的想法就可以了! 我试着这么做了…

我们的实验性游戏

我决定不再做另一个RPG游戏:首先是因为我没有任何有灵感的想法;其次是因为Bonfire太容易了,你将在Tiled中完成大约80%的工作。当你想探索框架的时候,它的能力和界限不是你所需要的。

你还记得一个老游戏,”坦克大战”吗?
在黑暗的背景下,建筑物都是用砖头砌成的,你的坦克保护着 “老鹰 “不受其他坦克的攻击,砖头是可以摧毁的。如果在那个 “古老 “的时代,人们能够创造这样的游戏,那么用现代工具包的所有力量来实现同样的游戏应该是非常容易的。

1. 控制器

我们在框架中捆绑了配置游戏控制器的类–移动设备用虚拟摇杆,桌面用按键:WASD。
但在我这里,应该只允许在第四个方向移动。在Bonfire中,每个方向都可以移动,所以我们需要削减不必要的功能。

我没有任何其他的解决方案,只是复制粘贴并重写整个混合器 “MovementByDirection “的内容。
这不是一个好的做法,但仍然是 “好的”–只是一个完整的重新实现的实体,不是一个问题 让我们继续前进…

2. 准备好敌人的实体

在这里,我们面临着类似的情况:我们需要将NPC绑定为4个方向的运动。
在这种情况下,我们不需要复制粘贴任何东西,但是为了实现所需的行为,你应该熟悉Bonfire的函数调用顺序:什么时候应该使用 “update()”,它与 “onMove() “有什么不同,什么是 “moveLeft”、”moveRight “等等。
学习内部框架结构、深入的类/混合体层次结构等不是问题,但这不是你可以称之为 “简单 “或 “直观 “的东西。
这里是Bonfire开始向你展示其复杂性的地方。

3. 为子弹准备好的实体,承受和接收伤害

这里是我们在Bonfire中实现的功能和实现游戏机制所需的功能之间的第一个冲突点。

在Bonfire中,子弹是通过 “FlyingAttackObject “类实现的。
它在与任何游戏组件碰撞的瞬间就会爆炸(如果它有任何关于碰撞的信息的话)。伤害只由 “可攻击 “类型的物体接受。看起来很有用。让我们与”坦克大战”的原始游戏机制进行比较。

  • 有坦克,它们接受伤害。标准Bonfire的机制在这里应该很好用。
  • 这里有可破坏的砖块,我不知道如何在不修改的情况下使用Bonfire的机制,因为砖块在第一次撞击后不会消失–只有砖块的一部分应该被删除。
  • 最后,我们在地图上有水。水砖应该是可以碰撞的,因为坦克不能游泳,但子弹应该在水面上自由飞行。

我们应该如何解决这个问题呢?我决定采用以下方法:

  • 坦克的一切都很好,让我们保持标准的力学。
  • 对于墙壁–第一个想法是用小Tile来建造地图,但这意味着大量疯狂的手工作业,因为最小的Tile尺寸是4px。另外,用引擎处理这么多的瓦片对象也是无效的。
    然后我选择了另一种方法:当子弹击中墙壁时,我们计算子弹的飞行矢量,并在这个方向上将Tile的尺寸缩小为瓦片全尺寸的一半。如果尺寸为零,那么这块Tile就应该被移除。
    该方案运行良好,但所有来自 “onCollision() “函数的标准机制应该从头开始重写。
  • 水。这里我们需要再次重写子弹类的 “onCollision() “函数以忽略与水的碰撞。
    这看起来并不复杂,但是一个问题接着另一个问题出现:Bonfire看不到不同类型的Tile之间有任何区别。
    为了让引擎看到水是水,砖是砖,我们应该在它的核心部分做一些额外的工作……我将在第7部分描述它。

4和5。敌人对玩家的移动、寻路。

在这一点上,所有框架的 “开箱即用 “的解决方案都变得无用。
敌人的移动只可能在第四个方向上进行。另外,有很多障碍物需要准确地绕过,以便有目的地到达某个地方。

捆绑式寻路也让人吃惊。
首先,它只为玩家实现。对于任何其他的游戏对象,你应该自己实现一切。
Bonfire的作者使用了第三方软件包https://pub.flutter-io.cn/packages/a_star_algorithm,直接使用它是很简单的,不需要额外的框架接口。
但是,我们又面临另一个问题:如果地图上有复杂的浮雕,而且点与点之间的距离很长,那么这个算法会使整个游戏变慢,我们会看到低FPS。
所有的操作都是单线程的,所以显示器上的坦克越多,游戏就会越慢。这就是为什么我最终停止了随机移动和在转角处改变方向的简单算法,以及当玩家在视线范围内时开火。
这并不完全是我想做的,但仍然比原游戏中的行为更聪明。

6. 支持平铺,游戏对象的不同高度。

我已经提到了对平铺地图的支持。
现在让我们来描述一下我所说的对象的高度。
有时候,一种物体应该被渲染在另一种物体之上。
例如,想象一下RPG游戏中的地牢和那里的火把或檐口。这些物体比玩家呆在地板上的高度要高,所以当玩家在它们下面移动时,它应该被画在上面。

在Flame中,我们有一个特殊的参数来控制这个。
游戏对象的 “优先级 “属性。它是一个整数,它越高,相应的物体就被放在越高的位置。
令人惊讶的是,Bonfire游戏不允许你使用这个功能!你唯一能做的就是影响游戏对象的位置。
你可以通过在Tiled编辑器中指定 “高于 “作为Tiled类型的名称来影响对象的摆放高度的唯一方法。有了 “高于”,这个Tile就会被渲染到游戏中所有物体的上方。
我还在最后的提交中发现了一个新的类型 “dynamicAbove”,但似乎没有办法直接控制 “优先级”。

Bonfire完全控制了 “优先级”,因为它是以游戏为导向的,摄像机被放置在45或70度的地方。它并不是简单地从上到下垂直观察。因此,一个物体有可能与另一个物体重叠,只是因为它在Y轴上比较低。
也许作者会扩展框架以允许使用多个高度层,但现在所有可用的高度层都被硬编码在 “LayerPriority “类中,添加自己的层的唯一方法是编辑框架的代码。

为什么我开始探索这个问题?
在最初的游戏中,背景只是一个简单的深色,而且在破坏后墙壁简单地消失。
我决定添加一个地面纹理,而不是深色背景,并添加灰烬纹理来代替被破坏的墙。
因此,灰烬的位置应该比地面高,但比任何其他游戏对象低。
不幸的是,我没有任何工具来实现这个目标,最后我fork了这个框架,并直接在其中添加了一个所需的逻辑。

当你期望只是简单地使用现成的 “开箱即用 “的解决方案时,这可不是什么好消息。

7. 将地图上的对象和Tile映射到Dart的自定义类中

为了实现我的目标,这是下一个需要重写的部分。

我很惊讶Bonfire不允许框架的用户看到Tile之间的差异。
这个功能只适用于Tiled “objects”,但它和 “tile “是完全不同的实体。
Tile “对象 “没有精灵或动画,它只能在地图上被定位和调整大小。最简单的描述是,”物体 “只是地图上有个别名称的方块。因此,我希望在Bonfire上能够创建单独的类来处理 “砖块 “,但这并没有实现。事实上,Bonfire的逻辑是这样的。

所有打算在游戏中进行互动的对象,都应该在Tiled中创建为 “对象 “实体。对象可以在地图加载时被转化为自定义类,但你应该手动进行精灵或动画加载。
所有的环境对象,比如墙壁、地板、非交互式的装饰物都应该被创建为Tile。但是你可以指定某些Tile应该是可碰撞的。
你可以在文档和教程中看到这一点。
第一次是我感到困惑的原因,为什么作者不在所需的类中直接映射Tile,但你可以看到这个功能还没有被实现。

在我看来,这个功能很关键,因为:

  • 在处理子弹的游戏逻辑时,我需要知道Tile的类型:以区别水的Tile和砖的Tile。
  • 我需要控制每块砖瓦在被子弹击中时缩小其尺寸。
  • 最后,我想实现额外的机制:允许玩家躲在森林里,对电脑敌人来说是隐形的。

所以,如果仅限于Bonfire的内置功能,我们也许应该在Tile层上面再画一层物体。这看起来比探索和修改框架的源代码要困难得多。

最后,我实现了额外的构建器–”tileBuilder “和 “decorationBuilder”。与内置的 “objectBuilder “一起,它可以为任何Tile对象创建每一个需要的类。下面是我的代码例子:

最令人失望的是

最后,我拥有了实现游戏逻辑的所有必要工具–多亏了fork和对框架的深入修改。

然后我面临另一个惊喜:在桌面上的性能大约是20-30FPS,而在手机上只有12-15FPS!

我开始探索这个问题,禁用了我以前写的所有游戏逻辑,但FPS总是在30左右。
之后,我试着去掉有碰撞的图层和装饰性Tile。
这有更好的效果,但仍然不是60FPS。最后,我禁用了所有的图形,玩家的坦克站在绝对黑色的屏幕上,游戏场周围有很小的砖块方块。
好吧,这样的配置达到了60FPS,但只是在你试图移动或开火或产生敌人之前才可以。

“也许我fork错了什么?” - 我曾想过,并开始建立和启动Bonfire的演示应用程序。
而且,令人惊讶的是! - 它也只在30FPS下工作!!。

在这一点上,我完成了我对框架或写在这个框架上的游戏的任何尝试。
在放弃一切之前,我再次研究了Bonfire的源代码,并将其内部逻辑与Flame进行了比较。现在我想我可以列举出Bonfire如此缓慢的原因了(而且,我确信,如果没有任何根本性的架构改变,它在未来也会变得缓慢)。

后台操作

除非你在 “更新 “中写下自己的逻辑,否则Flame在后台不会发生任何事情。
如果你启用它,只有碰撞会被计算。这就是全部。

在Bonfire中,至少有3个后台任务被永久地启动。

  • 循环浏览所有游戏对象并确定不可见的对象。不在视口中的物体将不会被处理。我确信这是一个可疑的解决方案:如果我们想避免不必要的渲染–这是图形引擎应该处理的工作,而且我相信它会做得更快、更有效。
  • 另一个循环穿过所有游戏对象来计算每个对象的 “优先级”。正如我在第6部分所说的,Bonfire会自动计算这个参数以实现 “倾斜的相机 “视图。
    但是,实施看起来并不优化:例如,会有一些额外的层,其中的对象从不相互重叠。或者说,如果位置没有变化就不重新计算。
  • 碰撞检测。作者试图通过传递当前不在视口中的物体来优化这一点。有了这种行为,你可以很容易地发现卡在纹理里的NPC,甚至是游戏场外的NPC,因为NPC可以在屏幕上看不到的时候穿过墙壁。

所有这些操作都会对你的游戏产生持续的负载,即使没有写任何一行自定义的游戏逻辑。负载量只是由你在Tiled编辑器中绘制的对象数量决定的。

后台操作的间隔(但所有操作都在单线程中)。

Tile的渲染

Flame在渲染Tile时不会创建任何额外的实体。
Tile不是游戏对象,不会被游戏循环事件处理。Flame使用SpriteBatch(https://pub.dev/documentation/flame/latest/sprite/SpriteBatch-class.html)来优化地图的渲染。
我想它可能会更优化,但无论如何它仍然比Bonfire的方法快。

Bonfire把每块Tile当作游戏对象。
对于每一块Tile,Bonfire都要进行计算,这块Tile是否可见?每块Tile都有可能包含碰撞,所以每块Tile也要进行检查。
最后,”优先级 “参数也要为每块Tile计算。

碰撞处理

Flame将所有碰撞对象分为两类。

  • 被动对象,比如墙。这类物体只是在其位置上移动,不会相互碰撞。
  • 主动对象,如玩家、子弹或敌人。这类物体主动移动,它可以相互碰撞,也可以与被动物体碰撞。

这种方法可以减少被动物体的计算时间。我确信被动物体是大多数典型游戏地图的主要组成部分。

在Bonfire中,物体类型之间没有区别。
每个物体都是平等的,每个物体都被计算。系统每次都会检查并报告你与两面静态墙的碰撞情况–这是你较新需要的信息(很可能)。
额外的开销是Bonfire的运动系统。
在每次对移动物体的 “update() “调用中,Bonfire都会在下一次勾选时将该物体移动到它的位置,并进行额外的碰撞计算。因此,你添加的移动对象越多,系统就越慢。

不要忘了,所有的计算都是在单线程上进行的,这里没有使用额外的 “隔离”–所以效率是非常有限的。

加载动画

这可能是最无害的问题,但无论如何。
在Bonfire中,大多数动画都是从头开始创建的:你传递文件名,框架切割图像,并从它制作独立的帧……只有图像本身被缓存,但如果把准备好的帧也缓存起来,那就很有用了。
我认为,这种方法有可能导致在需要创建新动画的时候出现短暂的 “冻结 “现象。

总结

我自己的决定是根本不使用Bonfire。
首先,我认为如果你fork它并为你的游戏目的重写它的内部逻辑,它可能是有用的。但这样的性能瓶颈是非常令人失望的。

尽管如此,仍然有很多有用的东西你可以从Bonfire中拿到你的项目中去。例如,一个操纵杆组件。或者全屏的动画色彩过滤器。
但你应该小心,并始终分析你要复制的东西。

Bonfire有机会克服这些问题吗?我认为没有进化的方法。
太多的代码已经写好了,系统的不同部分之间存在着大量的依赖关系,要使引擎的性能和灵活性提高,需要进行非常彻底的改变。即使作者决定重新设计所有的东西,它也将是完全不同的框架。

最后,Bonfire真的有它的应用领域吗?
我相信,如果你的项目不复杂,你可以使用它:地图小,对象数量少,游戏逻辑简单。如果你知道90%的工作都可以在Tiled中完成–Bonfire绝对是你的选择!

Flame本身会不会有更高的性能?肯定是的!
但不要指望如果你创建了成千上万的对象,引擎会轻松地处理它们。这个错误会导致你遇到Bonfire的性能问题。

如果你真的想让你的Flutter游戏更快,即使用Flame!
你也需要开发特殊的优化,适合你的游戏机制。