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 知识进阶 - 异步编程》


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

相关推荐
呆萌小新@渊洁2 小时前
后端接收数组,集合类数据
android·java·开发语言
ByteSaid3 小时前
Android 内核开发之—— repo 使用教程
android·git
Tom哈哈4 小时前
Android 系统WIFI AP模式
android
ShawnRacine4 小时前
Android注册广播
android
麦克尔.马7 小时前
一个安卓鸿蒙化工具
android·华为·harmonyos
国通快递驿站7 小时前
理解JVM中的死锁:原因及解决方案
android·java·jvm·spring·诊断
岸芷漫步8 小时前
Android从启动到ActivityThread的流程分析
android
锋风9 小时前
哔哩哔哩直播链接报403解决办法
android
西瓜本瓜@10 小时前
在Android中fragment的生命周期
android·开发语言·android studio·kt
老哥不老12 小时前
MySQL安装教程
android·mysql·adb