likes
comments
collection
share

Flutter&Flame游戏实践#17 | 二维无限标尺

作者站长头像
站长
· 阅读数 20

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

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

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


在空无一物的黑色空间之中,没有标志,没有参考,没有依靠,充满着未知的恐惧。

Flutter&Flame游戏实践#17 | 二维无限标尺


他说:固定一个标点吧 ,于是世界就诞生了。 世界因此有了位置,有了区域,有了形状,有了万物。在生命游戏的世界之中,需要一把尺子,度量世界的样貌。

Flutter&Flame游戏实践#17 | 二维无限标尺


一、以有限模拟无限

在二维空间中,有横纵两个纬度。视口区域是世界的展示范围。这个范围对应着横坐标范围和纵坐标范围。 在世界的无限空间之内,视口仅是微不足道的一部分。但视口可以进行拖拽缩放,来展示其他空间的内容。

这就让 有限的视口区域 ,有了展示 无限空间 的可行性。

本篇源码详见: 【toly_game/modules/life_game/lib/03】


1. 什么是二维无限标尺

如下所示,当原点固定,我们就可以在世界中建立坐标系来描述世界的位置信息。其中缩放和移动可以调节视口中展示区域的内容。你可以向上下左右无限地拖拽,展示世界的内容。

Flutter&Flame游戏实践#17 | 二维无限标尺

比如你向右移动,最右侧达到了 100 亿,并不会让网格或说世界从 0 一直绘制到 100亿 。而是:

100亿 - 屏幕容量 ~ 100 亿

也就是说,无论你移动到哪里,都 只会渲染屏幕区域,这就是保证无限空间下,内存和性能的关键。这也是 生命游戏 无限空间可行性的保证。本篇我们将着重探索这个 二维无限标尺 的实现方式。


2.实现思路

这个功能最难的地方在于:如何计算区域范围随手势交互的变换。 比如红色区域是 x 轴,实际刻度范围在 [0,size.width] 。这里设单位格长为 40,根据变换矩阵计算出刻度横坐标区间 -10,10

Flutter&Flame游戏实践#17 | 二维无限标尺

Flutter&Flame游戏实践#17 | 二维无限标尺

总得来说,就是根据移动和缩放的矩阵变换,来动态计算区域对应的坐标刻度范围;有了范围之后,就可以遍历刻度值,进行绘制操作。


二、数据层逻辑处理

首先来思考一下,当前功能需求中需要哪些数据。

  • 操作时的矩阵变换数据 Matrix4
  • 具有起止范围的区域数据 Area
  • 绘制时承载绘制信息的刻度数据 Scale
  • 具有横纵范围区域,并且进行变换刻度的 Range2d

1. 画板的数据

画板需要依赖交互过程中的 Matrix4 数据,并且交互时需要通知画板重新绘制。这里定义了 RulerValue 类,持有 Matrix4 数据 transform;并在设置 transform 时,触发 notifyListeners 通知更新。另外,根据变换矩阵,可以得到当前变化的 变换中心缩放的大小 .

class RulerValue extends ChangeNotifier {
  Matrix4 _transform = Matrix4.identity();

  Matrix4 get transform => _transform;

  set transform(Matrix4 value) {
    if(value==_transform) return;
    _transform = value;
    notifyListeners();
  }

  double get scale => _transform.getMaxScaleOnAxis();

  Offset get center => _transform.getTranslation().xy.toOffset();
}

这样就可以准备一个画板,将 RulerValue 作为 repaint(重新绘制) 的驱动力,如下所示:

Flutter&Flame游戏实践#17 | 二维无限标尺


2. 刻度数据 Scale

这里称每个宫格对应的坐标为刻度 Scale, 包括横纵两种形式,使用 Axis 进行区分;如下所示, Scale 还包含数值、主轴尺寸两个属性:

Flutter&Flame游戏实践#17 | 二维无限标尺

class Scale {
  final int value;
  final Axis axis;
  final double side;
  
  Scale(this.value, this.side, this.axis);
}

除了三个属性之外,还提供了 paintText 方法,便于在刻度区间内,绘制居中的文字;以及 link 方法,向传入的 path 对象中添加格线:

 void paintText(TextPainter painter, Canvas canvas, double extend) {
    painter.text = TextSpan(text: '$value', style: const TextStyle(fontSize: 12));
    painter.layout();
    Offset offset = switch (axis) {
      Axis.horizontal => Offset(
        value * side + side / 2 - painter.size.width / 2,
        extend / 2 - painter.size.height / 2,
      ),
      Axis.vertical => Offset(
        extend / 2 - painter.size.width / 2,
        value * side + side / 2 - painter.size.height / 2,
      ),
    };
    painter.paint(canvas, offset);
  }
  
  void link(Path path,double extend){
    if(axis==Axis.horizontal){
      path..moveTo(value * side, 0)..relativeLineTo(0, extend);
    }else{
      path..moveTo(0, value * side)..relativeLineTo(extend, 0);
    }
  }

3. 区域数据 Area 与 Range2d

Area 表示区域容纳的尺寸范围,只有起始和结尾两个 double 数据:

class Area {
  final double a;
  final double b;

  Area(this.a, this.b);

  @override
  String toString() {
    return 'Area[${a.toStringAsFixed(1)} ~ ${b.toStringAsFixed(1)}]';
  }
}

Range2d 包含横纵两个维度的区域范围 x,y,在构造中传入两个范围;这里定义了一个 Scales 的类型别名,用于只带两个维度的 Scale 列表:

typedef Scales = ({List<Scale> x, List<Scale> y});

class Range2d {
  final Area x;
  final Area y;

  Range2d({required this.x, required this.y});
Scales scales(double side, Offset c, double s) =>
    (x: _xBoxes(side, c, s), y: _yBoxes(side, c, s));
    
List<Scale> _xBoxes(double side, Offset c, double s) {
  var (start, end) = transform(x, c.dx, s);
  List<Scale> boxes = [];
  for (int i = start ~/ side - 1; i < end ~/ side + 1; i++) {
    boxes.add(Scale(i, side * s, Axis.horizontal));
  }
  return boxes;
}

List<Scale> _yBoxes(double side, Offset c, double s) {
  var (start, end) = transform(y, c.dy, s);
  List<Scale> boxes = [];
  for (int i = start ~/ side - 1; i < end ~/ side + 1; i++) {
    boxes.add(Scale(i, side * s, Axis.vertical));
  }
  return boxes;
}

(double, double) transform(Area area, double c, double s) {
  double len = area.b - area.a;
  double lenL = c - area.a;
  double lenR = area.b - c;
  return (
    c - len / s * (lenL / len) - c,
    c + len / s * (lenR / len) - c,
  );
}

4.交互过程中的数据变化

而 Flame 中视口的变换通过相机的 Transform2D,它是一个可监听对象。所以可以监听它的变化,来为 rulerValue 赋值。如果不是玩 Flame 的朋友,可以参考直接通过 rulerValue 的变换矩阵去操作画布变换:

Flutter&Flame游戏实践#17 | 二维无限标尺

---->[initState]----
game.camera.viewfinder.transform.addListener(_onTransformChange);

---->[dispose]----
game.camera.viewfinder.transform.removeListener(_onTransformChange);

void _onTransformChange() {
  rulerValue.transform = game.camera.viewfinder.transform.transformMatrix.clone();
}

三、视图层逻辑处理

视图层主要包括横轴坐标的刻度区域,以及中间的网格区域。通过 CustomPaint 进行绘制:

Flutter&Flame游戏实践#17 | 二维无限标尺

···dart CustomPaint( painter: RulerPainter(rulerValue), child: const Center(), ) ···


1. 画板类 RulerPainter

RulerPainter 继承自 CustomPainter,以 RulerValue 可监听对象为驱动。其中:

  • 定义了 _textPainter 成员绘制文字;
  • _storkPaint_gridPaint 分别是刻度线和网格画笔;_bgPainter 是背景色画笔;
  • paint 回调中基于 Canvas 和画布尺寸进行绘制操作
class RulerPainter extends CustomPainter {
  final RulerValue value;

  RulerPainter(this.value) : super(repaint: value);

  final TextPainter _textPainter = TextPainter(textDirection: TextDirection.ltr);

  final Paint _storkPaint = Paint()
    ..style = PaintingStyle.stroke
    ..color = Colors.white;

  final Paint _gridPaint = Paint()
    ..style = PaintingStyle.stroke
    ..color = Colors.grey
    ..strokeWidth = 0.5;
  
 final Paint _bgPainter = Paint()..color = const Color(0xff2a2a2a);
  
  @override
  void paint(Canvas canvas, Size size) {
    // TODO 绘制逻辑
  }

2.绘制刻度线

上面说过,绘制的数据 Scales 由 Range2d 通过 scales 方法得到。其中横坐标范围是 0~size.width, 纵坐标范围是 0~size.height,变换中心和缩放值通过 RulerValue 得到;

  @override
  void paint(Canvas canvas, Size size) {
    double side = 40;
    double scaleExtend = 20;
    double s = value.scale;
    Offset c = value.center;
    Range2d range = Range2d(x: Area(0, size.width), y: Area(0, size.height));
    Scales scales = range.scales(side, c, s);

scales 中记录着横纵刻度列表,每个刻度包含数值、边长、轴向数据。我们将根据这些数据,通过 drawScale 方法绘制刻度线:

Flutter&Flame游戏实践#17 | 二维无限标尺

横向的刻度线,需要根据变化中心的横坐标进行偏移,而且这个偏移不应该影响后续的绘制。可以使用 saverestore 这对操作。绘制的核心逻辑交由 paintScales 方法处理,纵坐标也是类似:

drawScale(canvas, size, c, scales, scaleExtend);

void drawScale(Canvas canvas, Size size, Offset c, Scales scales, double extend) {
  canvas.drawRect(Offset.zero & Size(size.width, 20), _bgPainter);
  canvas.save();
  canvas.translate(c.dx, 0);
  paintScales(canvas, scales.x, extend);
  canvas.restore();
  
  canvas.drawRect(Offset.zero & Size(20, size.height), _bgPainter);
  canvas.save();
  canvas.translate(0, c.dy);
  paintScales(canvas, scales.y, extend);
  canvas.restore();
}

paintScales 方法,会遍历传入的 Scale 数据列表,通过 Scale.link 连接路径线段;通过 Scale.paintText 方法绘制文字。这样就完成了坐标轴的表现:

Flutter&Flame游戏实践#17 | 二维无限标尺

void paintScales(Canvas canvas, List<Scale> boxes, double extend) {
  Path path = Path();
  for (int i = 0; i < boxes.length; i++) {
    Scale box = boxes[i];
    box.link(path, extend);
    box.paintText(_textPainter, canvas, extend);
  }
  canvas.drawPath(path, _storkPaint);
}

由于 Scales 数据是根据变换矩阵实时计算的,所以在平移和缩放的过程中,会自动计算更新刻度数据,从而完成刻度随变换同步进行的视觉功能。到这里,二维的无限标尺就已经完成了,你可以向四周拖拽到无穷无尽 ~


2. 绘制轴线示意

如下所示,左上角展示坐标轴文字,横纵坐标的零点给出两条线示意。这里定义 AxisPainter 来独立绘制坐标轴,方便统一修改或者移除:

Flutter&Flame游戏实践#17 | 二维无限标尺

class AxisPainter {
  final Size size;

  AxisPainter(this.size);

  void paint(Canvas canvas, TextPainter painter, Offset offset) {
    _drawText(canvas, painter);
    drawAxis(canvas, offset);
  }

  void drawAxis(Canvas canvas, Offset offset) {
    Paint paint = Paint()
      ..style = PaintingStyle.stroke
      ..color = Colors.cyanAccent;

    canvas.drawLine(Offset(offset.dx, 0), Offset(offset.dx, size.height), paint);
    canvas.drawLine(Offset(0, offset.dy), Offset(size.width, offset.dy), paint);
  }

  void _drawText(Canvas canvas, TextPainter painter) {
    Paint paint = Paint()..style = PaintingStyle.stroke;
    paint.color = const Color(0xffa0a0a0);
    canvas.drawRect(const Rect.fromLTWH(0, 0, 20, 20), Paint()..color = Colors.black);
    canvas.drawLine(Offset.zero, const Offset(20, 20), paint);
    paint.color = const Color(0xff2a2a2a);
    canvas.drawLine(const Offset(0, 20), const Offset(20, 20), paint);
    canvas.drawLine(const Offset(20, 0), const Offset(20, 20), paint);

    const TextStyle style = TextStyle(fontSize: 10, height: 1, color: Color(0xffa0a0a0));
    painter.text = const TextSpan(text: 'x', style: style);
    painter.layout();
    painter.paint(canvas, Offset(20 - painter.width - 4, 2));

    painter.text = const TextSpan(text: 'y', style: style);
    painter.layout();
    painter.paint(canvas, Offset(4, 20 - painter.height - 2));
  }
}

3. 绘制网格示意

网格对于坐标系来说意义重大,它在视觉上划分出空间与格点;但本质来看,网格只不过是刻度尺的附庸。二维空间的形成并不是因为有网格,而是因为有刻度的规范。

Flutter&Flame游戏实践#17 | 二维无限标尺

所以网格也就是刻度线拉长了而已,我们可以像绘制刻度尺那样绘制网格,代码如下:

void drawGrid(Canvas canvas, Size size, Offset c, Scales scales, double extend) {
  canvas.save();
  canvas.translate(c.dx, 0);
  paintGrid(canvas, scales.x, extend, size.height);
  canvas.restore();
  canvas.save();
  canvas.translate(0, c.dy);
  paintGrid(canvas, scales.y, extend, size.width);
  canvas.restore();
}

void paintGrid(Canvas canvas, List<Scale> boxes, double width, double extend) {
  Path gridPath = Path();
  for (int i = 0; i < boxes.length; i++) {
    Scale box = boxes[i];
    box.link(gridPath, extend);
  }
  canvas.drawPath(gridPath, _gridPaint);
}

4. 回复原位

当移动缩放比较乱时,可以通过将变换矩阵重置,从而让坐标系恢复到最初的位置。如下所示,在游戏主类中提供 fit 方法,将相机视口的变换矩阵重置,并偏移居中:

Flutter&Flame游戏实践#17 | 二维无限标尺

void fit() {
  camera.viewfinder.transform.transformMatrix=Matrix4.translationValues(size.x/2, size.y/2, 0);
}

到这里,维的无限标尺就完成了。有了无限标尺,等价于我们可以绘制无限的空间,从而让生命游戏可以有无尽的空间进行生存。只渲染视口范围的内容,也为内存和性能提供了保证。如下所示,将标尺的边长和之前的生命游戏宫格一致。就可以让之前的案例在坐标系统之中。下一章,我们将实现无限空间的生命游戏,敬请期待 ~

Flutter&Flame游戏实践#17 | 二维无限标尺

转载自:https://juejin.cn/post/7396252674239053860
评论
请登录