我一直想制作电子游戏。我的第一个 Android 应用程序帮助我获得了第一份工作,它是一款使用 Android 视图制作的简单游戏。之后,有很多尝试使用游戏引擎创建更精致的游戏,但由于时间不够或框架复杂,所有这些都失败了。但是当我第一次听说基于 Flutter 的 Flame 引擎时,我立即被它的简单性和跨平台支持所吸引,所以我决定尝试用它构建一个游戏。
我想从一些简单但仍然具有挑战性的事情开始,以感受引擎。本系列文章是我学习 Flame(和 Flutter)和构建基本平台游戏的旅程。我会尽量让它变得非常详细,所以它应该对任何刚刚接触 Flame 的人或一般的游戏开发人员都有用。
在 4 篇文章的过程中,我将构建一个 2d 横向卷轴游戏,其中包括:
能跑能跳的角色
跟随玩家的相机
滚动关卡地图,有地面和平台
视差背景
玩家可以收集的硬币和显示硬币数量的 HUD
赢屏
在第一部分中,我们将创建一个新的 Flame 项目,加载所有资产,添加一个玩家角色,并教他如何奔跑。
首先,让我们创建一个新项目。官方的Bare Flame 游戏教程很好地描述了执行此操作的所有步骤,因此请遵循它。
要添加的一件事:当您设置pubspec.yaml
文件时,您可以将库版本更新到最新的可用版本,或者保持原样,因为版本前的插入符号 (^) 将确保您的应用程序使用最新的非-破坏版本。 (插入符号语法)
如果您按照所有步骤操作,您的main.dart
文件应如下所示:
import 'package:flame/game.dart'; import 'package:flutter/widgets.dart'; void main() { final game = FlameGame(); runApp(GameWidget(game: game)); }
在我们继续之前,我们需要准备将用于游戏的资产。资产是图像、动画、声音等。出于本系列的目的,我们将仅使用在游戏开发中也称为 sprite 的图像。
构建平台游戏关卡的最简单方法是使用瓦片地图和瓦片精灵。这意味着关卡基本上是一个网格,其中每个单元格表示它代表的对象/地面/平台。稍后,当游戏运行时,来自每个单元格的信息将映射到相应的图块精灵。
使用这种技术构建的游戏图形可能非常复杂或非常简单。例如,在《超级马里奥兄弟》中,您会看到很多元素在重复。这是因为,对于游戏网格中的每个地面图块,只有一个地面图像代表它。我们将采用相同的方法,为我们拥有的每个静态对象准备一张图像。
我们还希望某些对象(例如玩家角色和硬币)具有动画效果。动画通常存储为一系列静止图像,每个图像代表一个帧。播放动画时,一帧接一帧地移动,营造出物体移动的错觉。
现在最重要的问题是从哪里获得资产。当然,你可以自己画,也可以委托给艺术家。此外,还有许多很棒的艺术家为开源贡献了游戏资产。我将使用GrafxKid的Arcade Platformer Assets pack 。
通常,图像资产有两种形式:精灵表和单个精灵。前者是一个大图像,包含所有游戏资产。然后游戏开发人员指定所需精灵的确切位置,然后游戏引擎将其从工作表中剪切下来。对于这个游戏,我将使用单个精灵(动画除外,将它们保存为一个图像更容易)因为我不需要精灵表中提供的所有资产。
无论您是自己创建 sprite 还是从艺术家那里获取,您可能都需要将它们切片以使其更适合游戏引擎。您可以使用专门为此目的创建的工具(如纹理打包器)或任何图形编辑器。我使用的是 Adobe Photoshop,因为在这个精灵表中,精灵之间的间距不相等,这使得自动工具很难提取图像,所以我不得不手动进行。
您可能还想增加资产的大小,但如果它不是矢量图像,生成的精灵可能会变得模糊。我发现一种非常适合像素艺术的解决方法是在 Photoshop 中使用Nearest Neighbour (hard edges)
调整大小方法(或在 Gimp 中将插值设置为无)。但是,如果您的资产更详细,它可能无法正常工作。
顺便解释一下,下载我准备的资产或准备你自己的资产并将它们添加到项目的assets/images
文件夹中。
每当您添加新资产时,您都需要像这样在pubspec.yaml
文件中注册它们:
flutter: assets: - assets/images/
未来的提示:如果您要更新已注册的资产,您需要重新启动游戏才能看到更改。
现在让我们实际将资产加载到游戏中。我喜欢将所有资产名称放在一个地方,这对于小型游戏非常有用,因为它更容易跟踪所有内容并在需要时进行修改。因此,让我们在lib
目录中创建一个新文件: assets.dart
const String THE_BOY = "theboy.png"; const String GROUND = "ground.png"; const String PLATFORM = "platform.png"; const String MIST = "mist.png"; const String CLOUDS = "clouds.png"; const String HILLS = "hills.png"; const String COIN = "coin.png"; const String HUD = "hud.png"; const List<String> SPRITES = [THE_BOY, GROUND, PLATFORM, MIST, CLOUDS, HILLS, COIN, HUD];
然后创建另一个文件,它将包含以后所有的游戏逻辑: game.dart
import 'package:flame/game.dart'; import 'assets.dart' as Assets; class PlatformerGame extends FlameGame { @override Future<void> onLoad() async { await images.loadAll(Assets.SPRITES); } }
PlatformerGame
是代表我们游戏的主要类,它扩展了FlameGame
,这是 Flame 引擎中使用的基本游戏类。这又扩展了Component
——Flame 的基本构建块。游戏中的一切,包括图像、界面或效果都是组件。每个Component
都有一个异步方法onLoad
,它在组件初始化时调用。通常,所有组件设置逻辑都在那里。
最后,我们导入了之前创建的assets.dart
文件,并将其添加as Assets
,以明确声明资产常量的来源。并使用方法images.loadAll
将SPRITES
列表中列出的所有资产加载到游戏图像缓存中。
然后,我们需要从main.dart
创建我们新的PlatformerGame
。修改文件如下:
import 'package:flame/game.dart'; import 'package:flutter/widgets.dart'; import 'game.dart'; void main() { runApp( const GameWidget<PlatformerGame>.controlled( gameFactory: PlatformerGame.new, ), ); }
所有准备工作都已完成,有趣的部分开始了。
在其中创建一个新文件夹lib/actors/
和一个新文件theboy.dart
。这将是代表玩家角色的组件:男孩。
import '../game.dart'; import '../assets.dart' as Assets; import 'package:flame/components.dart'; class TheBoy extends SpriteAnimationComponent with HasGameRef<PlatformerGame> { TheBoy({ required super.position, // Position on the screen }) : super( size: Vector2.all(48), // Size of the component anchor: Anchor.bottomCenter // ); @override Future<void> onLoad() async { animation = SpriteAnimation.fromFrameData( game.images.fromCache(Assets.THE_BOY), SpriteAnimationData.sequenced( amount: 1, // For now we only need idle animation, so we load only 1 frame textureSize: Vector2.all(20), // Size of a single sprite in the sprite sheet stepTime: 0.12, // Time between frames, since it's a single frame not that important ), ); } }
该类扩展了SpriteAnimationComponent
,它是一个用于动画精灵的组件,并且有一个 mixin HasGameRef
,它允许我们引用游戏对象以从游戏缓存加载图像或稍后获取全局变量。
在我们的onLoad
方法中,我们从我们在assets.dart
文件中声明的THE_BOY
精灵表创建一个新的SpriteAnimation
。
现在让我们将我们的玩家添加到游戏中!返回game.dart
文件并在onLoad
方法的底部添加以下内容:
final theBoy = TheBoy(position: Vector2(size.x / 2, size.y / 2)); add(theBoy);
如果你现在运行游戏,我们应该能见到男孩!
首先,我们需要添加从键盘控制 The Boy 的功能。让我们将HasKeyboardHandlerComponents
mixin 添加到game.dart
文件中。
class PlatformerGame extends FlameGame with HasKeyboardHandlerComponents
接下来,让我们回到theboy.dart
和KeyboardHandler
mixin:
class TheBoy extends SpriteAnimationComponent with KeyboardHandler, HasGameRef<PlatformerGame>
然后,向TheBoy
组件添加一些新的类变量:
final double _moveSpeed = 300; // Max player's move speed int _horizontalDirection = 0; // Current direction the player is facing final Vector2 _velocity = Vector2.zero(); // Current player's speed
最后,让我们覆盖允许监听键盘输入的onKeyEvent
方法:
@override bool onKeyEvent(RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed) { _horizontalDirection = 0; _horizontalDirection += (keysPressed.contains(LogicalKeyboardKey.keyA) || keysPressed.contains(LogicalKeyboardKey.arrowLeft)) ? -1 : 0; _horizontalDirection += (keysPressed.contains(LogicalKeyboardKey.keyD) || keysPressed.contains(LogicalKeyboardKey.arrowRight)) ? 1 : 0; return true; }
现在,如果玩家向右移动, _horizontalDirection
等于 1,如果玩家向左移动,则为 -1,如果玩家不移动,则为 0。然而,我们还不能在屏幕上看到它,因为玩家的位置还没有改变。让我们通过添加update
方法来解决这个问题。
现在我需要解释什么是游戏循环。基本上,这意味着游戏正在无限循环中运行。在每次迭代中,当前状态在Component's
render
方法中渲染,然后在update
方法中计算新状态。方法签名中的dt
参数是自上次状态更新以来的时间(以毫秒为单位)。考虑到这一点,将以下内容添加到theboy.dart
:
@override void update(double dt) { super.update(dt); _velocity.x = _horizontalDirection * _moveSpeed; position += _velocity * dt; }
对于每个游戏循环周期,我们使用当前方向和最大速度更新水平速度。然后我们用更新后的值乘以dt
来改变精灵的位置。
为什么我们需要最后一部分?好吧,如果你只用速度更新位置,那么精灵就会飞向太空。但是您可能会问,我们可以只使用较小的速度值吗?我们可以,但是玩家移动的方式会随着每秒帧数 (FPS) 速率的不同而不同。每秒帧数(或游戏循环)取决于游戏性能和运行的硬件。设备性能越好,FPS 越高,玩家移动速度越快。为了避免这种情况,我们让速度取决于从最后一帧开始经过的时间。这样精灵将在任何 FPS 上以类似的方式移动。
好的,如果我们现在运行游戏,我们应该看到:
太棒了,现在让我们让男孩在他走到左边时转身。将其添加到update
方法的底部:
if ((_horizontalDirection < 0 && scale.x > 0) || (_horizontalDirection > 0 && scale.x < 0)) { flipHorizontally(); }
相当简单的逻辑:我们检查当前方向(用户按下的箭头)是否与 sprite 的方向不同,然后我们沿水平轴翻转 sprite。
现在让我们也添加运行动画。首先定义两个新的类变量:
late final SpriteAnimation _runAnimation; late final SpriteAnimation _idleAnimation;
然后像这样更新onLoad
:
@override Future<void> onLoad() async { _idleAnimation = SpriteAnimation.fromFrameData( game.images.fromCache(Assets.THE_BOY), SpriteAnimationData.sequenced( amount: 1, textureSize: Vector2.all(20), stepTime: 0.12, ), ); _runAnimation = SpriteAnimation.fromFrameData( game.images.fromCache(Assets.THE_BOY), SpriteAnimationData.sequenced( amount: 4, textureSize: Vector2.all(20), stepTime: 0.12, ), ); animation = _idleAnimation; }
这里我们将之前添加的空闲动画提取到类变量中,并定义了一个新的运行动画变量。
接下来,让我们添加一个新的updateAnimation
方法:
void updateAnimation() { if (_horizontalDirection == 0) { animation = _idleAnimation; } else { animation = _runAnimation; } }
最后,在update
方法的底部调用此方法并运行游戏。
这就是第一部分。我们学习了如何设置 Flame 游戏、在哪里可以找到资产、如何将它们加载到您的游戏中,以及如何创建一个很棒的动画角色并使其根据键盘输入移动。这部分的代码可以在我的 github 上找到。
在下一篇文章中,我将介绍如何使用 Tiled 创建游戏关卡、如何控制 Flame 摄像机以及添加视差背景。敬请关注!
在每个部分的末尾,我将添加一个很棒的创作者列表和我从中学到的资源。