A preliminary exploration of the Flutter game engine Flame, taking you to implement a simple game

I am participating in the individual competition of the Nuggets Community Game Creativity Contest. For details, please see: Game Creativity Contest

foreword

When it comes to game development, the first thing that comes to mind is Cocos 2Dthese Unity 3Dpowerful game development engines. Many games on the market are developed based on these game engines. If we want to develop a game, we naturally think of choosing one of these development engines for development, but the development language used by these game engines may not match the development language we master. Of course, we can choose to go Learning a new language for development, after all, as a programmer, the learning ability is definitely not weak, but as a Flutter developer, I am wondering if there is a game development engine specifically for Flutter? As a cross-platform development framework, Flutter will naturally support cross-platform if you use Flutter to develop a game? The answer is yes, which is the Flamegame .

This article is a preliminary exploration of the Flame game engine. First of all, I will give a preliminary introduction to the Flame game engine, and implement a simple small game through the basic application of Flame. Game experience address: Stick to the end game , the game effect is as follows:

game

Introduction to using Flame

Flame is an open source Flutter-based game engine. The purpose of the Flame engine is to provide a complete set of solutions to common problems encountered in games developed with Flutter. Currently Flame provides the following functions:

  • game loop
  • Component/Object System (FCS)
  • Special effects and particle effects
  • Impact checking
  • Gesture and input support
  • Images, animations, sprites, and sprite groups
  • Some utility classes to simplify development

Because this article is a preliminary exploration of Flame, we will mainly introduce the first function: 游戏循环(game loop). The follow-up will introduce other functions of Flame one by one through a series of articles.

game creation

首先在 Flutter 项目依赖里添加 Flame 库的依赖,目前最新版本是 1.1.0

dependencies:
  flame: ^1.1.0
复制代码

然后创建一个类继承自 FlameGame

import 'package:flame/game.dart';

class CustomGame extends FlameGame{
  
}
复制代码

最后修改 main.dart 中 main 方法的 runApp 使用创建好的 CustomGame :

void main() {
  final game = CustomGame();
  runApp(GameWidget(game: game));
}
复制代码

runApp 需要传入一个 Widget,但是 FlameGame 并不是一个 Widget ,所以不能直接传入 runApp,需要使用 Flame 提供的 GameWidget, 其参数 game 传入上面创建的 CustomGame ,这样就创建好了一个游戏,只是现在我们什么都没有加,所以运行是一个黑的什么都没有。

游戏循环(game loop)

游戏循环是一款游戏的本质,即一组反复运行的代码,简单的说就是循环渲染画面到屏幕上。在游戏里我们常见的一个说法是:FPS(Frames Per Second) 即每秒多少帧,比如:60 FPS 代表一秒钟渲染 60 帧,换算下来就是 16 毫秒绘制一帧,整个游戏则是通过一帧一帧的画面循环绘制而成的。

那么在 Flame 中是怎样创建游戏循环的呢?FlameGame 提供了两个核心方法:updaterender,即更新和渲染,游戏运行时会循环调用 update 和 render 方法:

class CustomGame extends FlameGame{
  
  @override
  void render(Canvas canvas){
    super.render(canvas);
  }
  
  @override
  void update(double dt) {
    super.update(dt);
  }
}
复制代码

render 方法是用于渲染,有一个参数 canvas,这样我们就可以在 render 方法里通过 canvas 绘制我们想要的游戏内容;

update 方法用于更新游戏数据,其参数 dt 是时间间隔,单位是秒,即间隔多久调用一次 update 和 render 方法,前面说了 60 FPS 是 16 毫秒一帧,那么在 60 FPS 的情况下 dt 就等于 0.016 。

比如要在游戏里绘制一个圆,并让这个圆每一帧在 x 和 y 上各移动 1 个像素,则可以在 render 里使用 canvas 绘制一个圆,在 update 里更新圆心的位置,如下:

class CustomGame extends FlameGame{

  Offset circleCenter = const Offset(0, 0);
  final Paint paint = Paint()..color = Colors.yellow;

  @override
  void render(Canvas canvas){
    super.render(canvas);
    canvas.drawCircle(circleCenter, 20, paint);
  }

  @override
  void update(double dt) {
    super.update(dt);
    circleCenter =  circleCenter.translate(1, 1);
  }
}
复制代码

效果如下:

game1

生命周期

FlameGame 除了 update 和 render 方法外,还提供了一系列的生命周期方法,如下图:

Game Lifecycle Diagram

游戏初次添加到 Flutter 的 Widget 树时会回调 onGameResize, 然后依次回调 onLoadonMount ,之后将循环调用 update 和 render 方法,当游戏从 Flutter 的 Widget 树中移除时调用 onRemove 方法。

当游戏画布大小发生改变时会回调 onGameResize 方法,可以再该方法里重新初始化游戏里相关元素的大小和位置。

onLoad 在整个 FlameGame 的生命周期里只会调用一次,而其他生命周期方法都可能会多次调用,所以我们可以在 onLoad 中进行游戏的一些初始化工作。

实例:坚持到底小游戏

前面介绍了 FlameGame 的基本使用和生命周期,接下来就看看如何使用 FlameGame 实现一个小游戏。

游戏介绍

游戏名字叫坚持到底小游戏,游戏的玩法很简单,就是玩家操作游戏主角躲避四面八方发射过来的子弹,以坚持的时间为成绩,坚持的时间越长成绩越好,游戏的终极目标就是坚持100秒。

游戏的元素也很简单,包括:背景、主角、子弹、成绩、开始/重新开始按钮,接下来就一步步从零实现这个小游戏。

背景

首先第一步是绘制游戏的背景,因为这个游戏比较简单,游戏背景就是一个纯色,所以实现也比较简单,在 render 里使用 canvas 绘制一个全屏的矩形即可,代码如下:

class StickGame extends FlameGame{
  final Paint paint = Paint()..color = const Color.fromARGB(255, 35, 36, 38);
  final Path canvasPath = Path();
  
    @override
  Future<void>? onLoad() async{
    canvasPath.addRect(Rect.fromLTWH(0, 0, canvasSize.x, canvasSize.y));
    return super.onLoad();
  }
  
  @override
  void render(Canvas canvas){
    super.render(canvas);
    canvas.drawPath(canvasPath, paint);
  }
}
复制代码

声明一个 paint 变量,并设置其颜色即背景颜色,用于 canvas 绘制背景;声明 canvasPath 并在 onLoad 方法中为其添加一个矩形,矩形大小为整个画布的大小,其中 canvasSize 为 FlameGame 的变量,即画布大小;然后再 render 里调用 canvas.drawPath 进行绘制,这样就完成了背景的绘制。

主角

背景绘制完成后,接下来就是绘制我们游戏的主角了。在这个游戏里我们的主角就是一个圆,玩家可以拖动这个圆在画布范围内进行移动躲避子弹。

为了使代码易于管理,我们这里新建一个 TargetComponent 类用来专门处理游戏主角的绘制和相关逻辑。代码如下:

import 'dart:ui';
import 'package:flame/input.dart';
import 'package:flutter/material.dart';

class TargetComponent {
  final Vector2 position;
  final double radius;
  late Paint paint = Paint()..color = Colors.greenAccent;

  TargetComponent({required this.position, this.radius = 20});

  void render(Canvas canvas){
    
  }

}
复制代码

既然我们的主角是一个圆,那么要绘制一个圆就需要圆心、半径和颜色,所以为 TargetComponent 添加 position 和 radius 构造参数,用于传入圆心的位置和半径,默认半径为 20 ;创建 paint 并指定颜色值用于 canvas 绘制。

TargetComponent 中创建了一个 render 方法,参数是 Canvas,整个方法的定义与 FlameGame 中的 render 方法一直,该方法也是在 FlameGame 的 render 方法中进行调用,在 TargetComponent 的 render 方法中我们就可以实现圆的绘制了:

void render(Canvas canvas){
  canvas.drawCircle(position.toOffset(), radius, paint);
}
复制代码

在 StickGame 中创建 TargetComponent 并在 render 中调用 TargetComponent 的 render 方法:

class StickGame extends FlameGame{
  late TargetComponent target;
  
  @override
  Future<void>? onLoad() async{
    ...
    target = TargetComponent(position: Vector2(canvasSize.x/2, canvasSize.y/2));
    return super.onLoad();
  }
  
  @override
  void render(Canvas canvas){
    super.render(canvas);
		...
    target.render(canvas);
  }
}
复制代码

在 onLoad 中创建 TargetComponent 对象,位置传入的是画布的中心点,并在 render 方法中调用了 target 的 render 方法。实现效果如下:

image-20220412193545618

拖动

圆绘制好后,接下来就看怎么实现根据用户的拖动移动这个圆,这里有两个关键点,一个是监听用户拖动事件,一个是改变圆的位置。

Flame 提供了拖动事件的回调,只需 FlameGame 的实现类混入 HasDraggables 类然后实现对应的回调方法即可,如下:

class StickGame extends FlameGame with HasDraggables{
  @override
  void onDragStart(int pointerId, DragStartInfo info) {
    super.onDragStart(pointerId, info);
  }

  @override
  void onDragUpdate(int pointerId, DragUpdateInfo info) {
    super.onDragUpdate(pointerId, info);
  }
  
  @override
  void onDragCancel(int pointerId) {
    super.onDragCancel(pointerId);
  }
  
  @override
  void onDragEnd(int pointerId, DragEndInfo info) {
    super.onDragEnd(pointerId, info);
  }
}
复制代码

onDragStart 是拖动开始的回调,onDragUpdate 是拖动过程中的回调,onDragCancel 是取消拖动回调,onDragEnd 是拖动结束回调。

在 onDragStart 中我们判断拖动的是否为前面绘制的圆,并设置拖动标识,在 onDragUpdate 中去更新圆的位置。onDragCancel、onDragEnd 中取消拖动标识,实现如下:

	bool isDrag = false;

  @override
  void onDragStart(int pointerId, DragStartInfo info) {
    super.onDragStart(pointerId, info);
    if(target.path.contains(info.eventPosition.game.toOffset())){
      isDrag = true;
    }
  }

  @override
  void onDragUpdate(int pointerId, DragUpdateInfo info) {
    super.onDragUpdate(pointerId, info);
    var eventPosition = info.eventPosition.game;
    if (eventPosition.x < target.radius ||
        eventPosition.x > canvasSize.x - target.radius ||
        eventPosition.y < target.radius ||
        eventPosition.y > canvasSize.y - target.radius) {
      return;
    }

    if(isDrag){
      target.onDragUpdate(pointerId, info);
    }
  }

  @override
  void onDragCancel(int pointerId) {
    super.onDragCancel(pointerId);
    isDrag = false;
  }

  @override
  void onDragEnd(int pointerId, DragEndInfo info) {
    super.onDragEnd(pointerId, info);
    isDrag = false;
  }
复制代码

在 onDragStart 中判断拖动的点是否在游戏主角圆内,使用的是 Path 的 contains 方法判断,如果是则将 isDrag 设置为 true,并在 onDragCancel、onDragEnd 中将 isDrag 设置为 false。

然后在 onDragUpdate 中处理拖动更新,首先判断拖动的点是否在画布范围内,通过获取拖动的点 info.eventPosition.game 与画布范围以及结合圆的半径进行比较,如果超出画布范围则不处理,防止圆被拖到画布以外;最后调用 target.onDragUpdate 方法,实现如下:

  void onDragUpdate(int pointerId, DragUpdateInfo info) {
    var eventPosition = info.eventPosition.game;
    position.setValues(eventPosition.x, eventPosition.y);
    _updatePath();
  }

  void _updatePath() {
    path.reset();
    path.addOval(Rect.fromLTWH(position.x - radius, position.y - radius, radius * 2, radius * 2));
  }
复制代码

同样是先获取拖动的点坐标,然后将圆心位置设置为拖动坐标,最后调用 _updatePath 更新圆的 Path 路径,更新圆的 Path 路径主要是为了前面判断拖动是否在圆上以及后面为了检测圆与子弹的碰撞。最终实现效果:

game2

子弹

接下来就是绘制子弹,同样先建立一个子弹的组件:BulletComponent,子弹同样是一个圆,可以在画布中进行移动,拥有位置、移动速度、移动角度、半径、颜色属性,如下:

class BulletComponent{

  final Vector2 position;
  final double speed;
  final double angle;
  final double radius;
  late Paint paint = Paint()..color = Colors.orangeAccent;
  late Path path = Path()
    ..addOval(Rect.fromLTWH(position.x - radius, position.y - radius, radius * 2, radius * 2));

  BulletComponent({required this.position, this.speed = 5, this.angle = 0, this.radius = 10});
  
}
复制代码

默认半径为 10,默认角度为 0,默认速度为 5,颜色为 orangeAccent,同时为了便于后面检测子弹与游戏主角的碰撞,这里也定义了子弹的 Path 。

BulletComponent 组件实现 render 和 update 方法,用于绘制和更新,代码如下:

  void render(Canvas canvas){
    canvas.drawCircle(position.toOffset(), radius, paint);
  }

  void update(double dt){
    position.setValues(position.x - cos(angle) * speed , position.y - sin(angle) * speed);
    path.reset();
    path.addOval(Rect.fromLTWH(position.x - radius, position.y - radius, radius * 2, radius * 2));
  }
复制代码

绘制很简单,就是在 position 坐标的位置绘制一个指定半径的圆。更新则是按照设置的速度和角度计算出移动的 x、y 坐标,并将其设置给 position ,最后同样是同步更新子弹的 Path 路径。

创建子弹

子弹组件 BulletComponent 实现完成后,接下来就是创建子弹组件实例,需要为子弹设置位置、半径、速度和角度,那么这些值怎么来呢?

游戏中的子弹需要每隔一段时间随机出现在游戏画布的四周,且子弹的半径也是随机的,出现后以一定速度往游戏主角的目标点移动直到与目标相遇或移动到画布外。需要计算的几个点如下:

  • 位置:随机出现在画布四周
  • 半径:一定范围内随机(半径不能太大也不能太小)
  • 速度:随着时间推移子弹速度越来越快
  • 角度:通过子弹出现点和目标点计算子弹移动的角度

接下来就一步一步计算这些值,首先在 StickGame 中定义一个集合存放创建的子弹,然后定义一个创建子弹的方法:createBullet 并在 onLoad 方法中通过时间间隔循环调用,实现方法如下:

class StickGame extends FlameGame with HasDraggables{
  late Timer timer;
  List<BulletComponent> bullets = [];
  
  @override
  Future<void>? onLoad() async{
  	///....
    timer = Timer(0.1, onTick: () {
      createBullet();
    }, repeat: true);

    return super.onLoad();
  }  
  
    @override
  void render(Canvas canvas){
    super.render(canvas);
    ///...
    for (var bullet in bullets) {
      bullet.render(canvas);
    }
  }
  
  void update(double dt) {
    super.update(dt);
    ///...
   	for (var bullet in bullets) {
        bullet.update(dt);
    }
    timer.update(dt);
  }
  
  void createBullet() {
    ///...
  }
}
复制代码

在 onLoad 中通过 Timer 每间隔 0.1 秒调用一次创建子弹的方法,注意这里的 Timer 不是 Flutter SDK 中提供的 Timer 而是 Flame 库中提供的 Timer,是根据 update 的时间来计时的,所以需要在 update 中调用 Timer 的 update 方法才能生效,这样做的好处是当游戏暂停时 Timer 的计时也会暂停。

然后在 render 方法和 update 方法中遍历子弹的集合调用子弹的 render 方法和 update 方法用户绘制子弹和更新子弹的位置。

接下来关键代码就在 createBullet 中了:

  void createBullet() {
    /// 随机半径
    var radius = random.nextInt(10) + 5;
    /// 计算位置
    /// 是否在水平方向上,即画布的顶部和底部
    bool isHorizontal = random.nextBool();
    int x = isHorizontal ? random.nextInt(canvasSize.x.toInt()) : random.nextBool() ? radius : canvasSize.x.toInt() - radius;
    int y = isHorizontal ? random.nextBool() ? radius : canvasSize.y.toInt() - radius : random.nextInt(canvasSize.y.toInt());
    var position = Vector2(x.toDouble(), y.toDouble());
    /// 计算角度
    var angle = atan2(y - target.position.y, x - target.position.x);
    /// 计算速度
    var speed = seconds/10 + 5;
    bullets.add(BulletComponent(position: position, angle: angle, radius: radius.toDouble(), speed: speed));
  }
复制代码

首先随机得到 10 以内的数值然后加上 5 作为子弹的半径,再计算子弹的位置,因为计算位置的时候需要用到半径。

子弹位置的计算先随机一个 bool 值用于确定子弹位置是在画布的水平方向还是竖直方向,即是在画布的顶部底部还是左右两边,如果是水平方向那 x 坐标的值就是随机的,y 坐标的值则随机是 0 或者画布的高度,即随机顶部还是底部,如果是竖直方向则 y 坐标值是随机的,x 的坐标则随机是 0 或者画布的宽度,即画布的左边或右边,当然最后都要减去子弹的半径,防止子弹跑到画布外面去。

子弹角度的计算,知道了子弹的坐标、目标点的坐标,就可以通过 atan2 方法计算出角度了。

最后是速度,速度的初始值是 5 ,随着时间推移速度越来越快,所以这里用游戏时间 seconds 也就是游戏的秒数除以 10 再加上初始速度 5 作为子弹的速度。

效果如下:

game3

基本效果已经有了,但是还没有碰撞检测,发现子弹是穿过目标的,接下来就看看怎样实现碰撞检测。

碰撞检测

还记得前面实现游戏目标和子弹组件的时候里面都有一个 path 变量么,并且这个 path 会随着目标和子弹的更新一起更新,所以我们可以使用 Path 的 combine 方法来检测碰撞。

Path combine(PathOperation operation, Path path1, Path path2)
复制代码

combine 方法有三个参数,一个是操作类型,后面两个就是两个 path 对象,操作类型有 5 种,比如有两个圆重叠,对应 5 种类型的示意图如下:

image-20220417200849549

其中 intersect 就是我们需要的,即两个 Path 的相交,通过计算两个 Path 的相交的 Path,然后判断这个 Path 的长度是否大于 0 ,如果大于 0 说明两个 Path 有相交,即有重叠说明产生了碰撞,代码实现如下:

  bool collisionCheck(BulletComponent bullet){
    var tempPath = Path.combine(PathOperation.intersect, target.path, bullet.path);
    return tempPath.getBounds().width > 0;
  }
复制代码

在 update 遍历每个子弹,判断是否与目标有碰撞,如果有碰撞就结束游戏,所以这里增加一个 isRunning 变量,标记游戏是否运行,只有运行时才更新数据:

class StickGame extends FlameGame with HasDraggables{
  bool isRunning = true;
  
  ///...
  
  void stop(){
    isRunning = false;
  }
  
    @override
  void update(double dt) {
    super.update(dt);
    if(isRunning){
      timer.update(dt);
      for (var bullet in bullets) {
        if(collisionCheck(bullet)){
          stop();
          return;
        }else{
          bullet.update(dt);
        }
      }
    }
  }
}
复制代码

当检测到碰撞时就停止游戏,效果如下:

game4

计时

计时就是记录游戏时长,即游戏的成绩,这里创建一个 seconds 变量,即记录游戏运行了多少秒,然后每次在 update 中增加you'xi 时长,实现如下:

class StickGame extends FlameGame with HasDraggables{
  double seconds = 0;
  
  @override
  void update(double dt) {
    super.update(dt);
    if(isRunning){
      seconds += dt;
      ///....
    }
  }
}
复制代码

这样就完成了游戏时长的记录了。

文字

前面游戏基本功能基本完成,但是游戏的时长以及开始游戏、重新开始游戏以及游戏结束时游戏的成绩等文字需要显示,所以这里创建一个文字的组件 TextComponent,代码如下:

class TextComponent{
  final Vector2 position;
  String text;
  final Color textColor;
  double textSize;

  final Path path = Path();

  TextComponent({required this.position, required this.text, this.textColor = Colors.white, this.textSize = 40});


  void render(Canvas canvas){
    var textPainter = TextPainter(
        text: TextSpan(
            text: text,
            style: TextStyle(fontSize: textSize, color: textColor)),
        textAlign: TextAlign.center,
        textDirection: TextDirection.ltr);
    textPainter.layout(); // 进行布局
    textPainter.paint(canvas, Offset(position.x - textPainter.width / 2 , position.y - textPainter.height/2)); // 进行绘制
    path.reset();
    path.addRect(Rect.fromLTWH(position.x - textPainter.width / 2, position.y - textPainter.height/2, textPainter.width, textPainter.height));
  }

}
复制代码

TextComponent 有四个参数,文字的位置、文字内容、文字颜色、文字大小,实现的方法只有一个 render 方法,用于使用 canvas 绘制文字,这里绘制文字使用的是 TextPainter , 最后同样有一个 path 变量,用于记录绘制文字区域的路径,方便后面做文字的点击。

然后在 StickGame 里创建两个文字组件,一个用于显示成绩,一个用于显示开始游戏/重新开始游戏。

class StickGame extends FlameGame with HasDraggables{
  late TextComponent score;
  late TextComponent restartText;
  @override
  Future<void>? onLoad() async{
    score = TextComponent(position: Vector2(40, 40), text: "0", textSize: 30);
    restartText = TextComponent(position: Vector2(canvasSize.x/2, canvasSize.y/2), text: "START", textSize: 50);
    return super.onLoad();
  }  
  
  @override
  void render(Canvas canvas){
    super.render(canvas);
    ///...
    score.render(canvas);
    if(!isRunning){
       restartText.render(canvas);
    }
  }
}
复制代码

在 onLoad 中创建成绩和开始/重新开始游戏的文字组件,并在 render 中调用其 render 方法,这里只有当游戏停止时才调用 restartText 的 render 方法显示重新开始游戏。其中成绩显示在左上角,重新开始游戏显示到画布中间,默认 restartText 显示的是 START 即开始游戏。

既然有重新开始游戏,那就有开始游戏的方法,同时在结束游戏时也需要更新相应的数据,实现如下:

  void restart(){
    isRunning = true;
    bullets.clear();
    target.resetPosition();
    score.position.setValues(40, 40);
    score.textSize = 30;
    seconds = 0;
  }

  void stop(){
    isRunning = false;
    restartText.text = "RESTART";
    score.position.setValues(restartText.position.x, restartText.position.y - 80);
    score.text = "${seconds.toInt()}s";
    score.textSize = 40;
  }
复制代码

开始游戏时将 isRunning 设置为 true,然后清空子弹集合,重置游戏目标的位置,将成绩的显示放到左上角并设置成绩文字的大小为 30,游戏时长也重置为 0;游戏结束时将 isRunning 设置为 false,然后修改 restartText 的文字为 RESTART 即重新开始游戏,将成绩的文字移动到重新开始游戏文字的上方并修改其文字为游戏时长,并设置其文字大小为 40 。

点击

The text to start the game and restart the game is added before, but no click event is added to it. The method of adding a click event is similar to the method of adding a drag event before, just mix in the HasTappablesimplementation onTapUpmethod:

class StickGame extends FlameGame with HasDraggables, HasTappables{
  @override
  void onTapUp(int pointerId, TapUpInfo info) {
    super.onTapUp(pointerId, info);
    if(!isRunning && restartText.path.contains(info.eventPosition.game.toOffset())){
      restart();
    }
  }
}
复制代码

In the onTapUp method, determine whether the game is running, and then determine whether the text display area for starting/restarting the game contains the clicked point. If it does, it means that the click is to start/restart the game, and then the restart() method is called.

The final effect is the renderings released at the beginning of the article, as follows:

game

Recycle

The last step is to recover. When the bullet moves outside the canvas, the bullet needs to be recovered, that is, removed from the collection. The implementation is as follows:

  void checkBullets(){
    var removeBullets = <BulletComponent>[];
    for (var bullet in bullets) {
      if(!canvasPath.contains(bullet.position.toOffset())){
        removeBullets.add(bullet);
      }
    }
    bullets.removeWhere((element) => removeBullets.contains(element));
  }
复制代码

At last

This article leads you to a preliminary exploration of the Flame game engine, understands the basic use of FlameGame, and implements a simple game through FlameGame. In the process of implementing the game, you understand the use of drag events and click events. Of course, because this article is only a preliminary exploration of Flame, other Flame functions, such as Flame components, collision detection, etc., are not used in the process of implementing this small game. Using these functions can realize the corresponding game more quickly and conveniently. Function, more functions about Flame will be explained one by one in subsequent articles, so stay tuned!

Source: flutter_stick_game

The article has been simultaneously published to the public account: loongwind

Guess you like

Origin juejin.im/post/7087575465015115784