Flutter&Flame游戏实践#18 | 无限空间!

783 阅读10分钟

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


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

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

第一季:30 篇短文章,快速了解 Flame 基础。[已完结]
第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。

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


通过上一篇的 《二维无限标尺》 我们已经完成了 无限空间 的理论基础。这一篇,将结合生命游戏,完成无限空间的功能。最近正值巴黎奥运期间,下面以完成版的生命游戏为中国健儿加油 ~

本章代码地址: github.com/toly1994328…


一、以有限拟无限

无论多么快的计算机,无论多么高效的编程语言,对于真正的无限来说都是螳臂当车。当要渲染十万、百万、甚至上亿的点时。将数据一次性加入内存,每帧全部绘制一遍是及其粗劣的。此时算法策略将对性能产生绝对的影响,硬着头皮画 1 亿个点,那应用卡死是必然的。

image.png


其实在日常开发中,我们经常会遇到一维的无限,比如短视频、新闻、博客等 app ,它们的内容总量数以亿计。但是对于用户来说,并不需要一次性将所有的数据请求。因为用户无法在某一时刻消费所有数据,所以有种交互策略叫做 加载更多

掘金B站
619728a69f7ab10f1cd7a5428ed5cbc.jpg12bdddf0e5e522a4884c3c879bda0fc.jpg

对于 1 亿个点的绘制也是类似,一亿个点全部展示在屏幕上,已经超出了屏幕所能展示的能力。用户在其视口之内一定时有限的,有限数据 + 交互加载 就可以通过有限模拟出无限。而对于当前的 生命游戏 来说,这是二维的无限,包含宽高两个维度的无限,要比一维的列表展示要复杂很多。


二、从无限尺到无限空间

度量的标尺是决定空间存在的要素,建立坐标系之后,就可以通过坐标数据对点进行标记、绘制渲染。这样,视图的展示就等价于坐标数据的获取:比如下面坐标系中的白块展示了 字,就是收集对应坐标信息,在对应坐标中绘制白块:

image.png


当向左滑动时:右侧点的数据将被加入画面,从而展示出 中国

image.png


随着 视口的缩小: 会有更多的点进入可视区域,比如下面的 中国奥运 加油!。对于缩放来说,需要限制最小缩放值,来避免海量的点被同时绘制。比如下面最小缩放值时,将会有近 7000 的点。

image.png

这些看似只是白点,点在生命游戏中,是具有属性的空间,可以打开上帝视角,查看一下每个空间的拥挤程度:

image.png


随着 视口的放大: 可视区域的点数将急剧减少,比如下面就可以只加载和渲染 200 多个点,让你的计算机松口气:

image.png

到这里,大家应该理解了通过优限来模拟无限的思路。根据这个思路,其实可以完成很多重要的功能,比如说真正意义上的无限画板、无限坐标系中的函数图像绘制等。下面就让我们一起看看,在代码中如何实现这个酷酷的无限二维空间吧。


三、数据是决定界面渲染的关键

在二维无限尺中,有两组非常关键的数据:当前视口中区域横纵刻度范围。 如下所示,在当前变化操作下,横坐标在 6 ~ 48; 纵坐标在 42~63。这两组数据是我们将色块加入游戏空间的关键:

image.png

现在将这四个数据定义为 Range,包含两组 int 对,表示最大最小坐标区域。在上一章中 Range2d 用于根据变换计算坐标刻度尺列表,这里只需要知道四个边界值,可以给一个 range 方法进行计算范围:

typedef XY = (int, int);

typedef Range = ({XY min, XY max});

---->[Range2d]----
Range range(double side, Offset c, double s) {
  var (startX, endX) = transform(x, c.dx, s);
  var (startY, endY) = transform(y, c.dy, s);
  return (
    min: (startX ~/ side - 1, startY ~/ side - 1),
    max: (endX ~/ side + 1, endY ~/ side + 1),
  );
}

后续需要校验一个点是否在 Range 区域内,可以拓展方法进行支持:

extension RangeCheck on Range {
  bool contains(XY point,{double cache=0}) {
    bool inXRange = point.$1 >= this.min.$1-cache && point.$1 <= this.max.$1+cache;
    bool inYRange = point.$2 >= this.min.$2-cache && point.$2 <= this.max.$2+cache;
    return inXRange && inYRange;
  }
}

知道渲染的刻度范围,那么仅渲染范围内的数据就易如反掌:如下所示,在 SpaceManager 中,可以计算出边界区域后,遍历横纵坐标区域,这样只有 Range 区域的空间会被加入到世界中,就达到了我们的目的:

---->[SpaceManager]----
double x = game.size.x;
double y = game.size.y;
Matrix4 m4 = game.camera.viewfinder.transform.transformMatrix;
Range2d range2d = Range2d(x: Area(0, x), y: Area(0, y));
double s = m4.getMaxScaleOnAxis();
Offset c = m4.getTranslation().xy.toOffset();
Range range = range2d.range(side, c, s);

bool see = game.frameEvolve.seeWorld;
for (int x = range.min.$1; x < range.max.$1; x++) {
  for (int y = range.min.$2; y < range.max.$2; y++) {
    XY point = (x, y);
    bool alive = frame.spaces[point] ?? false;
    int? value = frame.spaceValueMap[point];
    if(value!=null){
      add(Space(point, alive: alive, value: value, see: see));
    }
  }
}

四、游戏空间的边界

现在世界的每帧(Frame) 和之前最大的不同在于:生存的空间并不是指定的行列数,而是可以随演化而变换的区域。如下所示,第一帧中细胞存在的范围如红框所示,也就是说,演化时只需要计算红框中的数据即可:

image.png

在演化过程中,生存空间是在实时变化的,有可能逐渐变小,也可能不断扩张:

image.png


现在有个数学问题,已知一个坐标列表,如何得到坐标所在区域的最大值和最小值。代码如下所示,当前空间中存活的坐标由 Frame#spaces 枚举进行维护;getRange 方法用于获取当前生存空间的坐标范围;具体逻辑是遍历坐标点,维护四个 int 值,记录遍历过程中最大最小的横纵坐标值:

Range? getRange() => _rangeOfPoints(spaces.keys);

Range? _rangeOfPoints(Iterable<XY> points) {
  if (points.isEmpty) return null;
  var (minX, minY) = points.first;
  var (maxX, maxY) = points.first;
  for (XY point in points) {
    if (point.$1 < minX) {
      minX = point.$1;
    }
    if (point.$1 > maxX) {
      maxX = point.$1;
    }
    if (point.$2 < minY) {
      minY = point.$2;
    }
    if (point.$2 > maxY) {
      maxY = point.$2;
    }
  }
  return (min: (minX, minY), max: (maxX, maxY));
}

这样在每次演化时,获取生存边界,然后遍历坐标维护 spaceValueMap 的值即可。每次演化完毕,可以移除掉生存空间之外的空间。从而避免无用的内存浪费:

void _calcSpaceValue() {
  Range? range = getRange();
  if (range == null) return;
  int cache = 1;
  int startX = range.min.$1 - cache;
  int startY = range.min.$2 - cache;
  int endX = range.max.$1 + cache;
  int endY = range.max.$2 + cache;
  for (int y = startY; y <= endY; y++) {
    for (int x = startX; x <= endX; x++) {
      int count = _calculate(x, y);
      spaceValueMap[(x, y)] = count;
    }
  }
  // 每次演化完后,移除  range 之外的无用数据
  spaceValueMap.removeWhere((e,v)=>!range.contains(e,cache: cache+1));
}

到这里,可视区域的空间渲染就完成了,接下来需要在移动和缩放的交互变换中,实时更新渲染的刻度范围,从而达到相对的无限。


五、变换中的操作

上一篇只是对游戏中的 Camera 进行简单的变换,本章将对交互变化进行封装和优化。让它不仅可以用于 Flame ,也可以用于普通的 Flutter 项目中,毕竟它们的本质都是基于 Matrix4 进行变换处理的。


1. 定义接口与混入类

Transformable 接口用于定义移动和缩放变换的上层抽象,包括行为、数据和回调:

abstract class Transformable {
  void scale(double scale, Offset origin);

  void translation(Offset delta);

  Matrix4 get transform;

  void onMatrixChange(Matrix4 m4);
}

TransformableMixin 实现 Transformable,根据 transform 矩阵,实现 scale 和 translation 两个方法的具体逻辑。其中缩放需要以 origin 为缩放中心,这涉及到了如何将一个点,根据 Matrix4 进行变化,转化到变换后的坐标系中:

mixin TransformableMixin implements Transformable {

  @override
  void scale(double scale, Offset origin) {
    Matrix4 m4 = transform.clone();
    Vector2 center = transform.globalToLocal(Vector2(origin.dx, origin.dy));

    Matrix4 scaleM = Matrix4.diagonal3Values(scale, scale, 0);
    Matrix4 moveM = Matrix4.translationValues(center.x, center.y, 0);
    Matrix4 backM = Matrix4.translationValues(-center.x, -center.y, 0);
    m4.multiply(moveM);
    m4.multiply(scaleM);
    m4.multiply(backM);
    if (m4.getMaxScaleOnAxis() < 0.3) return;
    onMatrixChange(m4);
  }

  @override
  void translation(Offset delta) {
    Matrix4 m4 = transform;
    Matrix4 opM = Matrix4.translationValues(delta.dx, delta.dy, 0);
    opM.multiply(m4);
    onMatrixChange(opM);
  }
}

可以对 Matrix4 添加一个拓展方法,用于将制定的点转换为变换后坐标系的坐标:

extension Matrix4Point on Matrix4{
  Offset globalToLocal(Offset point) {
    final m = storage;
    var det = m[0] * m[5] - m[1] * m[4];
    if (det != 0) {
      det = 1 / det;
    }
    final x = ((point.dx - m[12]) * m[5] - (point.dy - m[13]) * m[4]) * det;
    final y = ((point.dy - m[13]) * m[0] - (point.dx - m[12]) * m[1]) * det;
    return  Offset(x, y);
  }
}

在 Flutter 中,我们可以维护 Matrix4 数据,在 Canvas 绘制的时候进行变换。其中本质上 Flame 的 Camera 也是在绘制前进行变换的:

image.png


2. Flame 中使用 TransformableMixin

对于 Flame 的 Camrea 来说,变换矩阵的维护者是其 Viewfinder#Transform2D 中的 _transformMatrix 成员:

image.png

所以可以继续派生一个 TransformGame 混入类,专门处理 FlameGame 的变换操作。在其中可以访问 FlameGame 的成员,以及通过复写变换方法,来拦截控制变换的效果。比如设置是否启用变换,以及缩放的最小值;在 onMatrixChange 回调中修改 transformMatrix 的值,更新相机的变换矩阵:

注: transformMatrix 设置方法暂时还未在 flame 中,本项目中引用的是我 fork 的分支。

mixin TransformGame<T extends World> on TransformableMixin, FlameGame<T> implements Transformable {
  bool get enable;

  @override
  void scale(double scale, Offset origin) {
    if (!enable || scale < 0.3) return;
    super.scale(scale, origin);
    paused = false;
  }

  @override
  void translation(Offset delta) {
    if (!enable) return;
    super.translation(delta);
    paused = false;
  }

  @override
  Matrix4 get transform => camera.viewfinder.transform.transformMatrix;

  void onTransformTick();

  @override
  void onMatrixChange(Matrix4 m4) {
    camera.viewfinder.transform.transformMatrix = m4;
    onTransformTick();
}

当变换发生时,会触发 onTransformTick 回调,在其中通知世界进行重新渲染。其实很好理解,因为渲染的是视口区域的空间,那么移动和缩放必然会引发其他空间变得可视,所以需要重新渲染:

image.png


3. 限制更新的频率

由于拖拽、缩放的事件触发频率非常高,每次都重新渲染世界将会带来非常大的开销。所以可以限制更新的频率,这里根据时间戳额定间隔来限制 onTransformTick 触发的频率。其实最好的方式是通过节流来处理,这点后续会进行优化:

int get tickInterval;
int _lastTransformTick = 0;
@override
void onMatrixChange(Matrix4 m4) {
  camera.viewfinder.transform.transformMatrix = m4;
  // 限制触发 onTransformTick 的频率
  int now = DateTime.now().millisecondsSinceEpoch;
  if (now - _lastTransformTick < tickInterval) {
    return;
  }
  onTransformTick();
  paused = false;
  _lastTransformTick = now;
}

4. 手势交互与触发变换

当我们把前面的变换逻辑处理完毕时,只需要在对应的手势事件中调用变换方法即可。这里绘制和移动都使用 Flutter 层的 Listener 组件进行实践监听:

image.png

void _onPointerSignal(PointerSignalEvent event) {
  if (event is PointerScrollEvent) {
    bool larger = event.scrollDelta.dy < 0;
    double newZoom = larger ? 1 + 0.05 : 1 - 0.05;
    if (newZoom < 0.01 || newZoom > 20) return;
    transformable.scale(newZoom, event.localPosition);
  }
}

void _onPointerMove(PointerMoveEvent event) {
  transformable.translation(event.delta);
  onPaint(event.localPosition);
}

void _onPointerDown(PointerDownEvent event) {
  onPaint(event.localPosition);
}

到这里,生命游戏的无限空间的核心代码就介绍完毕了,更多的细节方面,大家可以自己查阅源码。下一篇,我们将继续完善生命游戏,基于本地数据库,实现绘制的的空间可存储;另外有了本地数据库,就可以收录 生命游戏 世界图书馆,从而更便于体验与交流生命游戏,敬请期待~