Flutter&Flame游戏实践#05 | 打砖块 - 基础功能

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!


Flutter&Flame 游戏开发系列前言:

该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台原生级 渲染框架,兼具 全端 跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。

第一季:30 篇短文章,快速了解 Flame 基础。[已完结]

第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。

两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》【github 项目首页】 查看。


一、资源准备

巧妇难为无米之炊,对于游戏而言,资源素材是非常重要的一环。itch.io 是一个面向独立游戏开发者和游戏玩家的在线游戏分发平台。其中也有很多人分享,非常多优秀的 可免费商用 游戏素材包。感谢这些资源创作者的无私奉献,本接下来的游戏将使用下面的素材包:

screamingbrainstudios.itch.io/tinybreakou...

素材包开源协议: Creative Commons Zero v1.0 Universal (CCO 可商用)


1. 精灵图的制作

下载的资源包里有很多小图片,我们可以通过 精灵图制作工具 将小图片资源整合在一起。这里使用 TexturePacker 工具,将图片文件夹拖入其中即可。

工具可以导出一张 png 图片,和一个 json 文件。其中 json 文件记录着每个小图片的文件名、尺寸、位置信息。为了更便捷地访问精灵图,可以解析这个 json 文件,根据 文件名 访问对应的精灵图。


这里称每个小图片为一个帧 Frame,其中每帧必要的信息有三个:

  • 名称: name
  • 资源尺寸: sourceSize
  • 资源位置坐标: srcPosition

如下定义一个 Frame 类型进行维护,并提供 fromMap 构造根据 map 数据创建 Frame 对象:

dart 复制代码
--->[packages/flame_ext/lib/texture_loader.dart]----
class Frame {
  final String name;
  final Vector2 sourceSize;
  final Vector2 srcPosition;

  Frame({
    required this.name,
    required this.sourceSize,
    required this.srcPosition,
  });

  factory Frame.fromMap(dynamic map) {
    return Frame(
        name: map['filename'],
        sourceSize: Vector2(
          map['frame']['w'].toDouble(),
          map['frame']['h'].toDouble(),
        ),
        srcPosition: Vector2(
          map['frame']['x'].toDouble(),
          map['frame']['y'].toDouble(),
        ));
  }
}

2. 精灵图的解析

整张精灵图就是由若干个 Frame 构成的列表,如下定义 TextureLoader 类型负责维护精灵图的加载。 在 load 方法中传入 json 和图片资源,解析 json 为 Frame 列表添加元素;重载 [] 运算符,便于根据文件名得到对应的 Sprite 对象:

dart 复制代码
--->[packages/flame_ext/lib/texture_loader.dart]----
class TextureLoader {
  final List<Frame> _frames = [];
  late final Image _sprites;
  
  Future<void> load(String jsonAsset, String imageAsset) async {
    _frames.clear();
    String data = await rootBundle.loadString(jsonAsset);
    List<dynamic> textures = json.decode(data)['textures'];
    for (int i = 0; i < textures.length; i++) {
      dynamic texture = textures[i];
      _frames.addAll((texture['frames'] as List).map(Frame.fromMap));
    }
    _sprites = await Flame.images.load(imageAsset);
  }
  
  /// 根据名称访问 _frames 中对应的精灵图
  Sprite operator [](String name) {
    Frame frame = _frames.singleWhere((e) => e.name == name);
    return Sprite(
      _sprites,
      srcPosition: frame.srcPosition,
      srcSize: frame.sourceSize,
    );
  }
}

上面的解析类完成之后,以后通过 TexturePacker 工具打包的精灵图,就可以很方便地访问。


3. 图片精灵的展示

如下,打砖块的游戏主类是 BreakBricksGame ,在 onLoad 回调中通过 TextureLoader#load 方法加载资源;加载完成后,通过 loader['Paddle_A_Blue_96x28.png'] 即可访问到对应文件名的精灵图。

dart 复制代码
class BreakBricksGame extends FlameGame {
  TextureLoader loader = TextureLoader();

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();
    await loader.load(
      'assets/images/break_bricks/break_bricks.json',
      'break_bricks/break_bricks.png',
    );
    Sprite sprite = loader['Paddle_A_Blue_96x28.png'];  
    add(SpriteComponent(sprite: sprite));
  }
}

打砖块的游戏中主要有三个角色,现将他们的单体呈现在屏幕上,如下所示:

  • 底部的碰撞体: Paddle
  • 小球: Ball
  • 砖块列表: Brick
dart 复制代码
---->[lib/break_bricks/01/heros/paddle.dart]----
class Paddle extends SpriteComponent with HasGameRef<BreakBricksGame>{
  @override
  FutureOr<void> onLoad() {
    sprite = game.loader['Paddle_A_Blue_96x28.png'];
    return super.onLoad();
  }
}
---->[lib/break_bricks/01/heros/ball.dart]----
class Ball extends SpriteComponent with HasGameRef<BreakBricksGame>{
  @override
  FutureOr<void> onLoad() {
    sprite = game.loader['Ball_Blue_Shiny-32x32.png'];
    return super.onLoad();
  }
}
---->[lib/break_bricks/01/heros/bricks.dart]----
class Brick extends SpriteComponent with HasGameRef<BreakBricksGame>{
  @override
  FutureOr<void> onLoad() {
    sprite = game.loader['Colored_Blue-128x32.png'];
    return super.onLoad();
  }
}

二、挡板移动与小球碰撞

第一步,来实现一下最简单的小球的碰撞反弹。如下所示,小球会以一定的初速度运动,碰到四壁和挡板时,会反弹向另一边:


1. 挡板的移动

挡板可以通过鼠标拖拽或者 键控制移动,游戏主类 BricksGame 中:

  • 混入 DragCallbacks 支持拖拽事件;
  • 混入 KeyboardEvents 支持键盘事件;
  • 混入 HasCollisionDetection 支持碰撞检测
dart 复制代码
class BricksGame extends FlameGame with DragCallbacks, KeyboardEvents, HasCollisionDetection {

覆写 onDragUpdate 方法,监听拖拽手势交互的事件,根据偏移量更新挡板的 x 坐标,使之在水平方向移动。另外 clamp 方法可以限制数字的最小和最大边界。挡板不能移出边界,所以需要限制最大横坐标是 width - paddle.width

dart 复制代码
---->[lib/bricks/02/bricks_game.dart]----
@override
void onDragUpdate(DragUpdateEvent event) {
  super.onDragUpdate(event);
  double dx = event.localDelta.x;
  double max = width - paddle.width;
  paddle.x = (paddle.x + dx).clamp(0, max);
}

覆写 onKeyEvent 方法,监听键盘点击事件。其中回调 KeyEvent 对象有三种派生类型:

  • KeyDownEvent : 键盘的按下事件。
  • KeyUpEvent : 键盘的抬起事件。
  • KeyRepeatEvent : 按键重复事件,也就是按下键盘不伸手,会多次触发的事件。
dart 复制代码
---->[lib/bricks/02/bricks_game.dart]----
@override
KeyEventResult onKeyEvent(KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
  super.onKeyEvent(event, keysPressed);
  if (event is KeyDownEvent || event is KeyRepeatEvent) {
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft: paddle.moveBy(-moveStep);
      case LogicalKeyboardKey.arrowRight: paddle.moveBy(moveStep);
    }
  }
  return KeyEventResult.handled;
}

如下所示,这里在按下或者长按时,触发挡板角色的 moveBy 方法进行移动。而不是直接修改 x 的值,因为 moveBy 方法使用 MoveToEffect ,可以在指定时间内平滑移动,从而避免突然位置变化带来的不佳体验。

dart 复制代码
---->[lib/bricks/02/heroes/paddle.dart]----
void moveBy(double dx) {
  double newX = position.x + dx;
  if (newX < 0) {
    x = 0;
    return;
  }
  if (newX > game.width - width) {
    x = game.width - width;
    return;
  }
  add(MoveToEffect(
    Vector2(newX, position.y),
    EffectController(duration: 0.1),
  ));
}

2. 小球与挡板的碰撞反弹

在恐龙跳跃的小游戏中,介绍过简单的碰撞检测。其核心是为构件添加碰撞检测区域,混入 CollisionCallbacks ,在回调事件中监听碰撞时机。对于小球来说可以添加 CircleHitbox 设置圆形的碰撞检测区域:

下面代码中 v 表示小球的速度,并在 update 方法中通过根据 v*dt 更新小球坐标位置 position, 从而实现小球的匀速运动。另外混入了 CollisionCallbacks ,可以覆写 onCollisionStart 回调监听到碰撞开始的事件:

dart 复制代码
---->[lib/bricks/02/heroes/ball.dart]----
class Ball extends SpriteComponent with HasGameRef<BricksGame>, CollisionCallbacks {

  @override
  void onGameResize(Vector2 size) {
    double initY = 80;
    double initX = size.x / 2 - width / 2;
    position = Vector2(initX, initY);
    super.onGameResize(size);
  }

  @override
  FutureOr<void> onLoad() {
    sprite = game.loader['Ball_Blue_Shiny-32x32.png'];
    add(CircleHitbox());
    return super.onLoad();
  }

  /// 小球速度
  Vector2 v = Vector2(0, 0);

  @override
  void update(double dt) {
    super.update(dt);
    position += v * dt;
  }

  void run() {  
    if (v.x == 0 && v.y == 0) {  
      v = Vector2(-250, 250);  
    }  
  }

  @override
  void onCollisionStart(Set<Vector2> intersectionPoints, PositionComponent other) {
    // TODO 碰撞逻辑处理
  }
}

在小球碰撞到挡板时,这里先简单做个反弹的处理。如下所示,小球以 v 的速度撞向挡板,以 v' 的速度反弹。反弹后球速度的特点是: 水平速度 vx 不变, 竖直速度 vy 方向相反:

也就是说,小球碰撞到挡板时,只需要 vY = -vY 即可:

dart 复制代码
void _handleHitPaddle(Vector2 position) {
  v.y = -v.y;
}

3. 小球与四壁的碰撞反弹

游戏中需要限制小球的移动范围,这里可以添加一个游戏区域的构件,作为限制区间。比如下面的 Playground ,大小和窗口一致,并添加 RectangleHitbox 使四周支持碰撞检测:

dart 复制代码
class Playground extends RectangleComponent with HasGameReference<BricksGame> {
  Playground() : super(
    paint: Paint()..color = const Color(0xff000000),
    children: [RectangleHitbox()],
  );


  @override
  FutureOr<void> onLoad() async {
    super.onLoad();
    size = Vector2(game.width, game.height);
  }
}

对于小球碰撞四壁,也可以进行类似的反弹。碰撞时可以获得接触点的坐标。根据坐标信息可以知道碰到的是哪个壁。水平方向的碰壁(左右),将 vX 反向;竖直方向的碰壁(上下),将 vY 反向。 如下通过 _handleHitPlayground 方法处理,其中 areaSize 是墙壁尺寸。

dart 复制代码
---->[lib/bricks/02/heroes/ball.dart]----
void _handleHitPlayground(Vector2 position, Vector2 areaSize) {
  if (position.y <= 0) {
    v.y = -v.y; // 上壁
  } else if (position.x <= 0) {
    v.x = -v.x; // 左壁
  } else if (position.x >= areaSize.x) {
    v.x = -v.x; // 右壁
  } else if (position.y >= areaSize.y) {
    v.y = -v.y; // 下壁
  }
}

onCollisionStart 回调中有两个参数,第一个是碰撞接触点的集合;第二个参数是碰撞到的另一个构件。这样就可以根据 PositionComponent 的类型知道,碰撞的是墙壁 Playground 还是挡板 Paddle,从而进行不同的逻辑处理。

dart 复制代码
---->[lib/bricks/02/heroes/ball.dart]----
@override
void onCollisionStart(Set<Vector2> intersectionPoints, PositionComponent other) {
  if (other is Playground) {
    _handleHitPlayground(intersectionPoints.first, other.size);
  } else if (other is Paddle) {
    _handleHitPaddle(intersectionPoints.first);
  }
  super.onCollisionStart(intersectionPoints, other);
}

4. 四周拐角的碰撞检测优化

在开发过程中遇到一个很诡异的事,如下所示,当小球恰好弹到拐角。有概率会出边界,这是很严重的问题:

通过日志分析,在拐角处碰到两边时,只算是一次碰撞,上边界的反弹没有触发,所以球直接飞出:

修复这个问题可以给四角范围进行额外的判定。比如弹到边角时原路弹回:

dart 复制代码
---->[lib/bricks/02/heroes/ball.dart]----
void _handleHitPlayground(Vector2 position, Vector2 areaSize) {
  // 四周拐角
  if (position.x < height  && position.y < height
      || position.x < height &&  position.y > areaSize.y - height
      || position.x > areaSize.x - height &&  position.y > areaSize.y - height
      || position.x > areaSize.x - height &&  position.y < height
  ) {
    v.y = -v.y;
    v.x = -v.x;
    return;
  }
  if (position.y <= 0) {
    v.y = -v.y; // 上壁
  } else if (position.x <= 0) {
    v.x = -v.x; // 左壁
  } else if (position.x >= areaSize.x) {
    v.x = -v.x; // 右壁
  } else if (position.y >= areaSize.y) {
    v.y = -v.y; // 下壁
  }
}

三、 砖块的管理与碰撞分析

到这里小球和四壁及挡板的碰撞就完成了。面看一下砖块构件列表的维护,以及小球和砖块的碰撞处理逻辑。资源中的砖块样式很多,这样先用短的蓝色砖块实现基础功能,后续再拓展更多功能。


1. 砖块管理器 BrickManager

和之前的云朵、障碍物类似,对于游戏中出现若干次的个体,一般通过管理器来维护。比如这里在 BrickManager 负责维护砖块列表,下图中是一个 2 行 10 列 的砖块阵:

BrickManager 在 onLoad 回调方法中,通过 _createBricks 创建 row 行 column 个砖块 (Brick) 。这里暂时将所有的砖块尺寸固定为 64*32 ; 这样创建过程中,通过行号和列号 (i,j) 来控制砖块摆放的坐标。

dart 复制代码
---->[lib/bricks/02/heroes/bricks.dart]----
class BrickManager extends PositionComponent {
  final int column;
  final int row;

  BrickManager({
    this.column = 10,
    this.row = 2,
  });

  @override
  FutureOr<void> onLoad() {
    addAll(_createBricks());
    width = 64.0 * column;
    return super.onLoad();
  }

  List<Brick> _createBricks() {
    List<Brick> bricks = [];
    for (int i = 0; i < row; i++) {
      for (int j = 0; j < column; j++) {
        Brick brick = Brick();
        brick.x = 64.0 * j;
        brick.y = 32.0 * i;
        bricks.add(brick);
      }
    }
    return bricks;
  }
}

2. 球与砖块的碰撞

小球在碰撞到砖块时,砖块需要移除;并且小球反弹:

其中最重要的任务是校验小球和砖块碰撞:小球撞到砖块的上下边界时,y 速度反向;小球撞到砖块的左右边界时,x 速度反向。碰撞回调中可以拿到砖块组件以及碰撞点:

这样以砖块左上顶点作为原点,可以得到碰撞点相对于砖块左顶点的坐标 hitP。根据这个坐标,可以感知碰到的方位。比如在 hitP.y 等于砖块高度时,说明碰撞发生在底部;hitP.y 等于 0 说明是顶部碰撞。左右同理:

dart 复制代码
---->[lib/bricks/02/heroes/ball.dart]----
void _handleHitBrick(Vector2 hitPos, Brick brick) {
  Vector2 hitP = hitPos - brick.position;
  if (hitP.y <= 0) {
    // 顶部碰撞
    v.y = -v.y;
  } else if (hitP.x <= 0) {
    // 左部碰撞
    v.x = -v.x;
  } else if (hitP.y >= brick.height ) {
    // 下部碰撞
    v.y = -v.y;
  } else if (hitP.x >= brick.width) {
    // 右部碰撞
    v.x = -v.x;
  }
  brick.removeFromParent();
}

碰撞完成后,将 break 从场景中移除,这样就可以完成最近的碰撞砖块,让小球反弹,并砖块消失的效果。


3. 砖块碰撞逻辑的优化

目前小球和砖块的碰撞检测逻辑有一个问题,当小球碰撞到两个砖块的的连接处。onCollisionStart 会先后触发两次,这样会导致小球的速度反向两次 从而无法反弹。

解决这个问题的思路是:

当碰撞到连接处时,只响应最开始碰到的砖块,忽略到第二次碰撞。

我想到的是将碰撞检测通过一个 bool 值 _lockedHitBrick 进行锁定,下面的 _lockCollisionTest 方法传入一个回调,表示碰撞的具体逻辑,当锁定时不做任何处理。触发完成后,通过 scheduleMicrotask 开启微任务将 _lockedHitBrick 置为 false。

dart 复制代码
---->[lib/bricks/02/heroes/ball.dart]----
// 锁定砖块碰撞检测
bool _lockedHitBrick = false;

void _lockCollisionTest(VoidCallback callback){
  if (_lockedHitBrick) return;
  _lockedHitBrick = true;
  callback();
  scheduleMicrotask(() => _lockedHitBrick = false);
}

@override
void onCollisionStart(Set<Vector2> intersectionPoints, PositionComponent other) {
  //略同...
  if (other is Brick) {
    _lockCollisionTest(()=> _handleHitBrick(intersectionPoints.first, other));
  } 
}

这是一个非常细节的操作,可能很多朋友这里看不明白。这里稍微解释一下:

  • 因为源码中碰撞检测是的遍历循环 同步逻辑 。微任务的回调是异步的,会在同步逻辑执行完成再触发。
  • 所以第二个碰撞的回调会在微任务回调之前触发,此时 _lockedHitBrick 为 true ,将不会执行碰撞逻辑。
  • 碰撞检测循环完成后,微任务回调触发,将 _lockedHitBrick 置为false,从而不影响之后的碰撞检测。

对异步不太明白的朋友可以阅读我的异步专栏: 《Flutter 知识进阶 - 异步编程》


到这里,我们就完成了基本的小球碰撞和砖块消失的功能。这是打砖块的核心逻辑,后面章节中会逐步优化,打造一个完善的打砖块游戏,敬请期待 ~

相关推荐
君的名字28 分钟前
怎么判断一个Android APP使用了React Native 这个跨端框架
android·react native·react.js
君的名字1 小时前
怎么判断一个Android APP使用了Electron 这个跨端框架
android·javascript·electron
君的名字2 小时前
怎么判断一个Android APP使用了Qt 这个跨端框架
android·开发语言·qt
xzkyd outpaper4 小时前
Android中Framework用到了哪些跨进程通信方式
android·计算机八股
珹洺4 小时前
计算机操作系统(十二)详细讲解调计算机操作系统调度算法与多处理机调度
android·java·数据库
星释6 小时前
鸿蒙Flutter实战:25-混合开发详解-5-跳转Flutter页面
flutter·harmonyos
tmacfrank6 小时前
Android 网络全栈攻略(五)—— 从 OkHttp 拦截器来看 HTTP 协议二
android·网络·okhttp
不惜年少枉少年7 小时前
java Sm2SignWithSM3转php
android·java·php
元亓亓亓7 小时前
MySQL--day6--单行函数
android·数据库·mysql
个案命题8 小时前
鸿蒙Ability对比Android的Fragment
android·华为·harmonyos·鸿蒙·fragment