Flutter&Flame游戏实践#06 | 打砖块 - 世界与相机

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


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

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

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

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

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


一、从固定分辨率认识相机

设备屏幕的尺寸可谓千差万别,而且桌面端的应用窗口可以调整大小,这就使得游戏随尺寸变化变得复杂。对于某些游戏来说,希望是 分辨率恒定 。比如打砖块高度如果自适应屏幕,很小时就无法体验。


1. 固定分辨率的优势

下面的应用窗口不同,游戏场景的 比例保存不变;同时会根据分辨率进行 缩放,在界面中保持合适的位置。游戏场景之外的灰色区域可以做些背景点缀,这是一种解决游戏场景尺寸的方案。

如下所示,黑色区域代表游戏场景,蓝色是游戏之外的背景。可以看到在变动应用窗口尺寸时:

  • [1]. 游戏场景的长宽比保持不变。
  • [2]. 游戏场景的内容会随尺寸变更而缩放。
  • [3]. 在游戏场景缩放过程中,场景内的世界坐标保持不变。

2. 相机 Camera 和世界 World

我们知道 Flame 中的一切都是 Component 。在 FlameGame 中,存在两个 "开天辟地" 时就创建的两个角色 - 相机 Camera世界 World 。下面是 FlameGame 的源码:

  • 在构造时可以传入 world 和 camera 两个参数。
  • 当未传入时,会创建并添加默认的世界和相机。
  • FlameGame 支持一个 World 派生类的泛型。

游戏主类在继承 FlameGame 时可以声明世界的泛型类型,比如这里自定义 PlayWord。设置相机可以通过 super 构造传递世界和相机,这里通过 CameraComponent.withFixedResolution 构造创建指定分辨率的相机,宽高为 320*480 :

dart 复制代码
---->[lib/world/13/main.dart]----
class MainGame extends FlameGame<PlayWord> {
  MainGame() : super(
          world: PlayWord(),
          camera: CameraComponent.withFixedResolution(width: 320, height: 480),
        );

  late final Image spriteImage;
  @override
  Future<void> onLoad() async {
    spriteImage = await Flame.images.load('demos/c17_mini_rmbg.png');
  }

  @override
  Color backgroundColor() => const Color(0xff5EC8F8);
}

自定义 PlayWord 类作为游戏的世界,这里绘制了一个左上角坐标 (0,0) 、宽高为 80 的黑色矩形。从这里可以看出: 世界的原点在视口中心

dart 复制代码
class PlayWord extends World {
  @override
  void render(Canvas canvas) {
    super.render(canvas);
    canvas.drawRect(const Rect.fromLTWH(0, 0, 80, 80), Paint());
  }
}

此时,将矩形的左顶点坐标改为 (-160,-240), 宽高改为 320*480 ,就可以将视口区域着成黑色:

dart 复制代码
---->[lib/world/13/main.dart]----
class PlayWord extends World {
  @override
  void render(Canvas canvas) {
    super.render(canvas);
    canvas.drawRect(const Rect.fromLTWH(-160, -240, 320, 480), Paint());
  }
}

这样一个固定分辨率的视口就完成了,当拖拽改变应用窗口尺寸,视口区域就能保证分辨率不变,等比缩放了。


3. 世界中的角色

下面定义 Hero 展示一个魔法师图片, 将其加入到 PlayWord 世界中。可以看到其左上角放置在世界的中心,因为默认情况下构件的锚点在左上角:

dart 复制代码
class PlayWord extends World {  
  @override  
  FutureOr<void> onLoad() {  
     add(Hero());  
  // 略同...
}

---->[lib/world/13/main.dart]----
class Hero extends SpriteComponent with HasGameRef<GameWorld> {
  @override
  FutureOr<void> onLoad() {
    sprite = Sprite(game.spriteImage);
    debugMode = true;
    debugColor = Colors.white;
    return super.onLoad();
  }
}

如果想让角色的锚点在中心,可以将 anchor 设置为 Anchor.center。这样角色的锚点将放置在世界原点,如下所示:

dart 复制代码
class PlayWord extends World {  
  @override  
  FutureOr<void> onLoad() {  
     add(Hero()..anchor=Anchor.center);  
  // 略同...
}

二、认识 World 世界

世界和相机 构件是 FlameGame 的成员属性,是游戏主场景开始时的组成部分。在 Trex 游戏中,我们直接将角色放入到游戏主场景中,并没有使用到这两个构件。也就是说之,前我们在场景中添加的构件(下面灰色),相当于世界和相机的兄弟:


1. World 构件的源码

World 是一个 Component ,使用当使用世界时,相当于我们写的构件被添加到了 World 之下。

从源码的角度来看,World 只是一个非常非常普通的 Component。当两个构件有重合时,优先级高的会展示在上方。World 的优先级被设置为 -0x7fffffff,也就是说 一切角色在世界之上

dart 复制代码
class World extends Component implements CoordinateTransform {
  World({
    super.children,
    super.priority = -0x7fffffff,
  });

  @override
  void renderTree(Canvas canvas) {}

  @internal
  void renderFromCamera(Canvas canvas) {
    assert(CameraComponent.currentCamera != null);
    super.renderTree(canvas);
  }

  @override
  bool containsLocalPoint(Vector2 point) => true;

  @override
  Vector2? localToParent(Vector2 point) => null;

  @override
  Vector2? parentToLocal(Vector2 point) => null;
}

另外,World 实现了 CoordinateTransform 接口,该接口定义了 父坐标和本地坐标 之间转换的两个方法。在 World 中这两个方法返回 null, 表示 World 构件在逻辑上是顶层,没有必要进行坐标转换。

dart 复制代码
abstract class CoordinateTransform {
  Vector2? parentToLocal(Vector2 point);

  Vector2? localToParent(Vector2 point);
}
     

这就是 Flame 中 World 的全部代码,本身并没有复杂的逻辑。所以也不用太畏惧这个宏大的 数据概念,就是一个在最底下的空白构件而已。


2. 绘制世界坐标系

之前一个角色站在空荡荡的世界里,说世界的原点在视口中心,这句话大家可能也品味不出其中的价值。其实认识世界,最重要的是要理解 世界坐标系 。在现实世界中的含义就是 我在哪里 。接下来我就把世界坐标系画出来给你看,让你直观感受这个世界的存在。下面是一个 480*320 的世界:

  • 黑色区域是世界的范围。
  • 红色轴是世界坐标轴,两轴的交点是世界的原点。
  • 格线是间的距离是 20,白色文字标注了轴线的距离原点的位置。

坐标系的绘制过程,属于绘制知识,它只是一个视觉上的辅助,我将绘制逻辑封装为 WorldGrid 。这里就不专门介绍了,感兴趣的可以自己看一下源码实现,【world/14/world_grid.dart】

dart 复制代码
---->[lib/world/14/main.dart]----
class PlayWord extends World {
   // 略同...

  final WorldGrid _worldGrid = WorldGrid(
    axisColor: Colors.red,
    textColor: Colors.white,
    gridColor: const Color(0xff4AFFFF),
  );

  @override
  void render(Canvas canvas) {
    // 略同...
    _worldGrid.paint(canvas, kViewPort);
  }
}

对于固定分辨率的视口,当尺寸变化时,世界本身会 等比缩放 。如下所示,当世界缩小时,黑色区域在 逻辑上 依然是 480*320,因此固定分辨率的相机创建的是一个不受屏幕真实尺寸影响的 世界


3. World 和 Camera 的关系

想一下,这个不受屏幕真实尺寸影响的 世界 ,是谁的功劳? World 本身并没有干什么事,那剩下的参与者有且仅有 Camera。 那 World 和 Camera 是什么关系呢? 在 FlameGame 源码中,创建 Camera 之后,会将 _world 赋值给 _camera.world

这说明 Camera 持有 World 成员,这也是相机可以影响世界的原因。对于世界的认知就到这里,下面我们来初步认识这个非常重要的 Camera 角色。


三、相机对世界的变换

在介绍相机之前,先举个小场景引入一下:

将一张红色的塑料膜放在眼前,你将看到一个红色的世界。我们当然知道红膜本身并没有改变世界的能力,它改变的只是观察者的视觉呈现。

相机也是一样,它只是操作对画板 Canvas 的进行变换,改变对世界的观察角度。


1. 缩放变换

界面中有三种基础变换 移动缩放旋转。在 【world/14】 中,给出了操作世界变化的案例。如下,通过 [] 来放大和缩小世界的表现:

世界的变换效果,由 Camera 中的 Viewfinder 构件所决定。它本质上是维护 Matrix4 变换矩阵、在渲染时将变换施加到 Canvas 之上。为了使用方便 Viewfinder 中封装了简单的变换操作:通过 zoom 属性的控制缩放变换:

dart 复制代码
//  每次按下 ] 放大 0.1;
camera.viewfinder.zoom += 0.1;

//  每次按下 ] 缩小 0.1;
double newZoom = camera.viewfinder.zoom-0.1;
camera.viewfinder.zoom = max(0.1, newZoom);

2. 移动变换

如下所示,通过键盘的上下左右按键控制移动变换,视觉上是世界在移动。世界的移动和缩放,都不会超出视口的范围,也就是这里世界的显示区域,永远在 480*320 之内,超过的区域不会渲染:

和 zoom 类似,Viewfinder 这封装了 position 设置的方法更新世界的位置 :

dart 复制代码
camera.viewfinder.position -= Vector2(0, 10); //上
camera.viewfinder.position += Vector2(0, 10); //下
camera.viewfinder.position += Vector2(10, 0); //左
camera.viewfinder.position -= Vector2(10, 0); //右

3. 旋转变化

同理对于旋转变换,Viewfinder 这封装了 angle 设置的方法修改世界的旋转角度 :

dart 复制代码
camera.viewfinder.angle-= 2*(pi/180);

4. Viewfinder 变换的本质

稍微瞄一眼 Viewfinder 中的这三个变换的方法,可以看出他们都是通过 transfrom 成员实现的功能。


它的类型是 Transform2D,是 Flame 中对 matrix 变换矩阵的封装体。继承自 ChangeNotifier 说明其具有通知监听的能力:

dart 复制代码
/// Transform matrix used by the viewfinder.  
final Transform2D transform = Transform2D();

可以再向下深追一点, CameraComponent 渲染时,会先通过 Viewfinder 的变换矩阵,对 Canvas 进行操作,再渲染世界。这里就像是: 在眼前放一张变换能力的塑料膜,让你看到世界之前先施加变换

到这里,我们已经对 CameraComponent 进行有一个基本的了解。相机还有其他的特性,在后期用到时会继续介绍。对于打砖块游戏,了解到这里就够用了。


五、将打砖块改为固定分辨率

按照前面固定分辨率的方案,可以在视口区域展示游戏世界,这样应用层窗口的尺寸变化,只是对视口的缩放,从而可以保证游戏区域在 任何尺寸窗口 下表现的一致性。

- - -

2. BricksGame 代码处理

这里先暂定砖块供 9 列,每个砖块宽 64;游戏区域 1080:2400 的长宽比,该固定的尺寸定义为全局常量 kViewBox 。然后在 super 构造中指定相机和 PlayWorld 世界,

dart 复制代码
---->[lib/bricks/03/bricks_game.dart#BricksGame]----
const Size kViewPort = Size(64 * 9, 64 * 9 * 2400 / 1080);

class BricksGame extends FlameGame<PlayWorld> with KeyboardEvents, HasCollisionDetection {
  BricksGame()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: kViewPort.width,
            height: kViewPort.height,
          ),
          world: PlayWorld(),
        );

由于视口默认以中心为原点,之前是以左上角为原点放置打砖块内容的。可以通过设置 camera.viewfinder 的锚点,来确定世界坐标系原点的位置, Anchor.topLeft 表示以左上角为原点:

dart 复制代码
  TextureLoader loader = TextureLoader();
  @override
  FutureOr<void> onLoad() async {
    await loader.load(
      'assets/images/break_bricks/break_bricks.json',
      'break_bricks/break_bricks.png',
    );
    camera.viewfinder.anchor = Anchor.topLeft;
  }

3. PlayWorld 的代码处理

接下来,只要把之前放在游戏中的角色,放入到 PlayWorld 中即可。点击和拖拽事件也可以放入其中:

dart 复制代码
---->[lib/bricks/03/bricks_game.dart#PlayWorld]----
class PlayWorld extends World with HasGameRef<BricksGame>, DragCallbacks, HasCollisionDetection, TapCallbacks {

  Ball ball = Ball();
  Paddle paddle = Paddle();
  BrickManager brickManager = BrickManager();

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();
    add(Playground());
    add(paddle);
    add(ball);
    add(brickManager);
  }

  @override
  void onTapDown(TapDownEvent event) {
    ball.run();
  }

  @override
  void onDragUpdate(DragUpdateEvent event) {
    super.onDragUpdate(event);
    double dx = event.localDelta.x;
    double max = kViewPort.width - paddle.width;
    paddle.x = (paddle.x + dx).clamp(0, max);
  }
}

另外由于键盘事件只能在 FlameGame 中混入,无法放到 PlayWorld 中处理。键盘控制挡板移动的逻辑需要放到 BricksGame 中,通过 world 成员可以访问 paddle :

csharp 复制代码
---->[lib/bricks/03/bricks_game.dart#BricksGame]----
@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:
        world.paddle.moveBy(-moveStep);
      case LogicalKeyboardKey.arrowRight:
        world.paddle.moveBy(moveStep);
    }
  }
  return KeyEventResult.handled;
}

本篇小结

到这里,游戏的视口就优化完毕了。即使把窗口缩小到非常小,也不会影响游戏的功能。

本篇主要介绍了相机 Camera 和 世界 World 相关的概念,知识点不是很多,但非常重要。

我们目前完成了最核心的游戏逻辑功能,距离一个完整的游戏还有大距离。接下来对整体 UI 进行一些优化,以整体游戏的视角进行完善,比如游戏中的场景、关卡、道具、菜单、音效等方面。敬请期待 ~

相关推荐
数据猎手小k23 分钟前
AndroidLab:一个系统化的Android代理框架,包含操作环境和可复现的基准测试,支持大型语言模型和多模态模型。
android·人工智能·机器学习·语言模型
你的小101 小时前
JavaWeb项目-----博客系统
android
风和先行1 小时前
adb 命令查看设备存储占用情况
android·adb
AaVictory.2 小时前
Android 开发 Java中 list实现 按照时间格式 yyyy-MM-dd HH:mm 顺序
android·java·list
似霰3 小时前
安卓智能指针sp、wp、RefBase浅析
android·c++·binder
大风起兮云飞扬丶3 小时前
Android——网络请求
android
干一行,爱一行3 小时前
android camera data -> surface 显示
android
断墨先生4 小时前
uniapp—android原生插件开发(3Android真机调试)
android·uni-app
无极程序员5 小时前
PHP常量
android·ide·android studio
萌面小侠Plus6 小时前
Android笔记(三十三):封装设备性能级别判断工具——低端机还是高端机
android·性能优化·kotlin·工具类·低端机