Flutter 中构建游戏(Bonfire)

Author Avatar
Amos
发表:2022-06-28 00:56:09
修改:2022-07-15 00:12:03

在今年的 Google I/O 大会上,Flutter 团队使用 Flutter 以及 Firebase 构建了一款经典的弹球游戏,非常有趣,他们基于 Flutter 构建的 2D 游戏引擎 Flame 进行制作。

[图片]

I/O Pinball(体验地址

对于 Puzzles 或者文字类游戏,Flame 提供开箱即用的功能,例如:动画、物理、碰撞检测等,同时还利用了 Flutter framework 的基础内容,也就意味着使用 Flutter 构建应用程序,那么其实你已经具备使用 Flame 构建游戏所需的基础[1],在 Flame 之外还可以利用 Nakama、Firebase 便于构建多人游戏。

虽然 Google I/O 大会过去了一个月,热度已经退却,但不妨碍我们对于在 Flutter 中构建游戏的热情,后面我通过一个示例要点的演示,让我们能够对 Fluter 中构建游戏有个基础的认识。

游戏示例

首先我们来看看这个小示例最终构建完成的效果。

示例工程源码:https://github.com/AmosHuKe/Mood-Example
(实验室-游戏合集,详细位置查看 README 的项目结构说明)

游戏灵感来源:《20 Minutes Till Dawn》
图像素材来源:mini_fantasydungeontileset-ii

[图片]

Bonfire 构建游戏

在使用 Bonfire 前,请务必完整了解 Flame
Flame: https://github.com/flame-engine/flame/
Bonfire: https://github.com/RafaelBarbosatec/bonfire/

Bonfire 是基于 Flame 构建的包,所以 Flame 所有资源和类都可以与 Bonfire 一起使用,它提供了非常多组件、方法等,能够方便让我们快速构建 RPG 游戏和类似的 2D 游戏。

[图片]

以上角度构建游戏用 Bonfire 是理想的选择[3]

游戏循环 GameLoop

这是 Flame 中最重要的概念之一,因为通常来说应用在没有用户交互情况下不会更新 UI,保持 UI 静止状态。但在游戏中则是相反的—— UI 会持续的渲染,而且游戏状态会不断变化。
Flame 提供了一个 game widget,它内部管理了一个游戏循环,所以能恒定且高效地进行渲染。
Game 类包含了游戏组件以及其逻辑的实现,然后交给 widget 树中的 GameWidget
游戏循环对游戏的位置、状态等做出反应,发生更新和新的渲染,以便我们能够在碰撞、实体渲染、受伤等情况下,反馈出我们希望的游戏效果。[1]

通过了解 Flame GameLoop 的生命周期我们可以更好地在合适的触发时机处理事件,以达到我们想要的效果。

[图片]

Flame GameLoop 的生命周期[2]

例如:我们希望在玩家存活时不断生成敌人,如果敌人超过设定的数量就停止这次的生成。

利用生命周期 update 的更新机制(以秒为单位增量时间),来帮助我们不断地触发生成效果。  

/// 逻辑简写后的代码

/// 兽人生成延迟时间
async.Timer? _timerEnemyOrc;

/// Flame 生命周期 update
@override
void update(double dt) {
  if (isDead) return;

  /// 生成敌人:兽人
  _enemyOrcCreate();

  super.update(dt);
}

/// 生成敌人:兽人
void _enemyOrcCreate() {
  /// 每 2 秒生成
  if (_timerEnemyOrc == null) {
    _timerEnemyOrc = async.Timer(const Duration(milliseconds: 2000), () {
      _timerEnemyOrc = null;
    });
  } else {
    return;
  }

  /// 限制生成数量
  if (gameRef.enemies().length >= 100) return;

  /// 增加兽人实体
  gameRef.add(
    Orc(Vector2(0,0)),
  );
}

BonfireTiledWidget

BonfireTiledWidgetBonfire 中最基本的组件,通过它我们能快速配置构建游戏。
我们需要知道 BonfireTiledWidget 中每一层级的渲染顺序,方便我们在构建地图、装饰、玩家、敌人等组件时,能更好地处理它们的显示关系,如下图自下而上的顺序依次渲染。

[图片]

Bonfire 层级渲染关系(自下而上)[3]

@override
Widget build(BuildContext context) {
  return BonfireTiledWidget(
    constructionMode: false, // Debug 显示栅格属性,简化贴图构造并绘制栅格
    showCollisionArea: false, // Debug 显示碰撞区域
    gameController: GameController(), // 游戏控制器,你可以监听游戏的所有组件进行控制,或者添加新组件
    joystick: Joystick(), // 控制手柄,包含可独立控制的左右分屏自定义手柄,控制手柄可控制玩家,如果不配置,则会控制地图
    map: TiledWorldMap('tile/map.json', forceTileSize: tileSize, objectsBuilder: {}), // 地图配置,以及地图上对象组件生成,例如灯光、酒桶、可控制的怪物等装饰(GameDecoration)
    player: Player(), // 用户控制的玩家角色
    interface: PlayerInterface(), // 用户的界面,比如左上角的血条,或者可操作的自定义组件
    background: GameComponent(), // 创建视差、交互式、颜色等背景
    lightingColorGame: Colors.black.withOpacity(0.4), // 游戏常规照明配置
    cameraConfig: CameraConfig(), // 相机配置
    progress: Widget(), // 游戏加载时进入的加载组件
  );
}

Tiled 地图构建

地图作为 BonfireTiledWidget 中最开始的渲染层级,官方推荐使用 Tiled 进行地图构建。
在 Tiled 中,我们可以创建背景、建筑样式等场景(图块层),以及提供后续实体组件(敌人、灯光等)渲染需要的对象(对象层)。

[图片]

例如:我们需要地牢风格特点的火烛照明发光效果,那么在 Tiled 中创建对象层,画出火烛上需要照明发光的标记位置,每个对象命名为 light
在 BonfireTiledWidget 配置地图时利用 TiledWorldMapobjectsBuilder 将我们定义在地图上的对象 light 渲染上照明发光的组件实体。
我们也可以设计地图将能够控制的怪物放置在指定的对象位置。

BonfireTiledWidget(
  ...
  map: TiledWorldMap(
    ...
    'tile/map.json'
    forceTileSize: Size(),
    objectsBuilder: {
      'light': (properties) => Light(
        position: properties.position,
        size: properties.size,
      ),
    },
  ),
);

class Light extends GameDecoration with Lighting {
  Light({
    required super.position,
    required super.size,
  }) {
    setupLighting(
      LightingConfig(
        radius: width * 2,
        blurBorder: width * 1.5,
        color: Colors.orange.withOpacity(0.2),
        withPulse: true,
      ),
    );
  }
}

精灵图 Sprite

想必前端都很熟悉这个东西,为了减少请求数量提高加载速度的图像整合技术,简单来说就是一张图片已经包含了我们所有需要的样子(比如整个攻击的过程),通过定位坐标就能得到我们需要的图像效果。
定义完成精灵图便于我们之后在需要动画的地方可以直接赋值调用,当然精灵图还有其他用法,比如组合动画,合并图像等……

[图片]

小人全方向攻击的精灵图

/// Flame.images.load 默认资源目录为 assets/images/ 记得在 pubspec.yaml 中配置
Image spriteSheetPlayerAttack = await Flame.images.load('human_attack.png');

/// 定义需要显示图像的像素大小以及数量将图像分块,精灵图会按照速度依次显示,以达到一个逐帧动画的效果

/// 右下方攻击
static Future<SpriteAnimation> getAttackBottomRight() {
  return spriteSheetPlayerAttack
      .getAnimation(
        // 一组图像中每块的大小
        size: Vector2.all(21),
        // 一组图像按大小分割的数量
        count: 4,
        // 图像 y 轴开始的位置,比如一整个图像以 21 像素分组,
        // 那么 startDy: 0 就是第 1 组,startDy: 21 就是第 2 组,以此类推
        startDy: 0,
        loop: false,
        stepTime: animSpeed,
      )
      .asFuture();
}

/// 左下方攻击
static Future<SpriteAnimation> getAttackBottomLeft() {
  return spriteSheetPlayerAttack
      .getAnimation(
        size: Vector2.all(21),
        count: 4,
        startDy: 21,
        loop: false,
        stepTime: animSpeed,
      )
      .asFuture();
}

/// 右上方攻击
static Future<SpriteAnimation> getAttackTopRight() {
  return spriteSheetPlayerAttack
      .getAnimation(
        size: Vector2.all(21),
        count: 4,
        startDy: 42,
        loop: false,
        stepTime: animSpeed,
      )
      .asFuture();
}

/// 左上方攻击
static Future<SpriteAnimation> getAttackTopLeft() {
  return spriteSheetPlayerAttack
      .getAnimation(
        size: Vector2.all(21),
        count: 4,
        startDy: 63,
        loop: false,
        stepTime: animSpeed,
      )
      .asFuture();
}

GameComponent Mixins

只要是 GameComponent 都可以通过 mixin 添加一些内置的行为,比如说移动(Movement)、自动寻路(moveToPositionAlongThePath)、攻击(Attackable)、碰撞(ObjectCollision)等……

[图片]

Bonfire GameComponent 主要可用 mixin 的树[3]

例如:我们想让 SimplePlayer 能够发射远程火球攻击,并且通过控制手柄(JoystickAction)控制火球发射的角度。

SimplePlayer 继承于 Player ,本身就是 GameComponent,并且 Player 默认就 mixin 了 Movement、Attackable、MoveToPositionAlongThePath、JoystickListener、MovementByJoystick 的能力,所以我们的需求可以直接在 SimplePlayer 中得到满足和使用。
通过 joystickAction 方法监听我们的操作,将用户操作手柄的角度传入 simpleAttackRangeByAngle 远程攻击的角度,最终使用户的操作和远程攻击同步。

[图片]
class HumanPlayer extends SimplePlayer {
  ...
  async.Timer? _timerFireBall;

  @override
  void joystickAction(JoystickActionEvent event) {
    if (isDead) return;

    /// 判断操作手柄的ID以及按下移动手柄操作的动作,触发远程攻击
    if ((event.id == LogicalKeyboardKey.select.keyId || event.id == 2) &&
        event.event == ActionEvent.MOVE) {
      _actionAttackRange(event.radAngle);
    }
    super.joystickAction(event);
  }

  /// 远程攻击
  void _actionAttackRange(double fireAngle) {
    if (_timerFireBall == null) {
      _timerFireBall = async.Timer(const Duration(milliseconds: 50), () {
        _timerFireBall = null;
      });
    } else {
      return;
    }
    simpleAttackRangeByAngle(
      // 自定义精灵图(Sprite)中火球发射、摧毁的样子
      animation: SpriteSheetFireBall.fireBallAttackRight(),
      animationDestroy: SpriteSheetFireBall.fireBallExplosion(),
      size: Vector2(),
      // 同步用户手柄的操作角度
      angle: fireAngle,
      withDecorationCollision: false,
      speed: speed,
      damage: 50.0 + Random().nextInt(10),
      attackFrom: AttackFromEnum.PLAYER_OR_ALLY,
      marginFromOrigin: 30,
      collision: CollisionConfig(
        collisions: [
          CollisionArea.rectangle(
            size: Vector2(),
            align: Vector2(),
          ),
        ],
      ),
      lightingConfig: LightingConfig(
        radius: radius,
        blurBorder: blurBorder,
        color: Colors.deepOrangeAccent.withOpacity(0.4),
      ),
    );
  }
}

再来点奇怪的东西,如果我们把角度换成范围很大的随机数,那么我们就能得到一个火球倾泻的效果 😂

[图片]

玩家、Npc、敌人

Bonfire 中默认帮我们基于 GameComponent 封装了一些易用的组件。

  • Player 组件:SimplePlayerRotationPlayer,他们都继承于 Player,提供生命周期,并混入 Movement、Attackable、MoveToPositionAlongThePath 和 JoystickListener 行为。
  • Npc 组件:SimpleNpcRotationNpc,他们都继承于 GameComponent,提供生命周期,并混入 Movement 行为。
  • Enemy 组件:SimpleEnemyRotationEnemy,他们都继承于 Npc,提供生命周期,并混入 Attackable 行为(Npc 有了攻击行为就直接算作敌人了 😂)。
/// 比如我在 SimplePlayer 的基础上混入 Lighting(照明发光)、ObjectCollision(碰撞)
class HumanPlayer extends SimplePlayer with Lighting, ObjectCollision {
  HumanPlayer(Vector2 position)
  : super(
    position: position,
    /// 玩家操作行为对应触发的精灵图
    animation: SimpleDirectionAnimation(
      idleLeft: SpriteSheetPlayer.idleBottomLeft,
      idleRight: SpriteSheetPlayer.idleBottomRight,
      idleUp: SpriteSheetPlayer.idleTopRight,
      idleUpLeft: SpriteSheetPlayer.idleTopLeft,
      idleUpRight: SpriteSheetPlayer.idleTopRight,
      runLeft: SpriteSheetPlayer.runBottomLeft,
      runRight: SpriteSheetPlayer.runBottomRight,
      runUpLeft: SpriteSheetPlayer.runTopLeft,
      runUpRight: SpriteSheetPlayer.runTopRight,
      runDownLeft: SpriteSheetPlayer.runBottomLeft,
      runDownRight: SpriteSheetPlayer.runBottomRight,
    ),
    speed: speed,
    life: 500,
    size: Vector2(),
  ) {
    // 照明发光配置
    setupLighting(
      LightingConfig(
        radius: width * 2,
        blurBorder: width * 6,
        color: Colors.transparent,
      ),
    );
    // 碰撞配置
    setupCollision(
      CollisionConfig(
        collisions: [
          CollisionArea.rectangle(
            size: Vector2(),
            align: Vector2(),
          ),
        ],
      ),
    );
  }

  /// 生命周期 render
  /// 比如可以在这里进行玩家生命条的生成等
  @override
  void render(Canvas canvas) {
    super.render(canvas);
  }

  /// 生命周期 update
  /// 会持续触发,比如遇到怪物发起对话、耐久计算等
  @override
  void update(double dt) {
    super.update(dt);
  }

  /// 碰撞触发
  @override
  bool onCollision(GameComponent component, bool active) {
    /// 如果 active 为 false 则不发生碰撞
    /// 我们可以判断碰撞的 component 并改变 active 的值
    /// 达到碰到指定物体(怪物、墙、飞行物等)可以直接穿越过去的效果
    bool active = true;
    return active;
  }

  /// 自定义的操纵杆控制
  @override
  void joystickAction(JoystickActionEvent event) {
    super.joystickAction(event);
  }

  /// 操纵杆控制
  @override
  void joystickChangeDirectional(JoystickDirectionalEvent event) {
    super.joystickChangeDirectional(event);
  }

  /// 受伤触发
  @override
  void receiveDamage(AttackFromEnum attacker, double damage, dynamic from) {
    super.receiveDamage(attacker, damage, from);
  }

  /// 死亡
  @override
  void die() {
    super.die();
  }

  ...
}

总结

结合示例源码,我们能够对在 Flutter 中构建游戏有个基础的认识,借助 Flame 和 Bonfire 的能力,我们能快速构建出一款 2D 游戏,当然想要做得更好,我们可以通过 Flame 自定义复杂的场景需求,还需要学习更多,比如:游戏资源的管理、状态管理、更为完整的物理引擎 Forge2D ( Box2D ) 等等,Flame 能运用的场景肯定不止这些,或许我们可以尝试将它运用在一些用户基础场景帮助我们提高用户体验。

参考

转载请遵循 协议许可
本文所有内容严禁任何形式的盗用
本文作者:Amos Amos
本文链接:https://amoshk.top/2022062301/

评论
✒️ 支持 Markdown 格式
🖼️ 头像与邮箱绑定 Gravatar 服务
📬 邮箱会回复提醒(也许会在垃圾箱内)