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


theme: cyanosis

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


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

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

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

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


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

image.png


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

image.png


一、以有限模拟无限

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

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

本篇源码详见: 【tolygame/modules/lifegame/lib/03】


1. 什么是二维无限标尺

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

10.gif

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

100亿 - 屏幕容量 ~ 100 亿

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


2.实现思路

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

image.png

如果向左移动 40,则横坐标区间变为 -9,11 ,以此类推。同理,缩放也是如此,至于缩放时,如何根据缩放中心对尺寸区域进行变换,在 《编程与数学 | 一维空间的中心缩放》 一文中已经详细地介绍过了。

image.png

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


二、数据层逻辑处理

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

  • 操作时的矩阵变换数据 Matrix4
  • 具有起止范围的区域数据 Area
  • 绘制时承载绘制信息的刻度数据 Scale

    - 具有横纵范围区域,并且进行变换刻度的 Range2d

1. 画板的数据

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

```dart 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(重新绘制) 的驱动力,如下所示:

image.png


2. 刻度数据 Scale

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

image.png

```dart class Scale { final int value; final Axis axis; final double side;

Scale(this.value, this.side, this.axis); } ```

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

```dart 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 数据:

```dart 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 列表:

```dart typedef Scales = ({List x, List y});

class Range2d { final Area x; final Area y;

Range2d({required this.x, required this.y}); ```

Range2d 的核心功能就是基于 变换数据区域范围 计算出 Scales 横纵刻度列表:其中最核心的 transform 就是 《编程与数学 | 一维空间的中心缩放》 一文中介绍的小知识点,虽然只有五行代码,但却是当前功能实现核心中的核心。
变换后的范围区间除以宫格尺寸,就可以得到期望的刻度范围,刻度前后预留一个刻度的缓存值。遍历得到两个维度的 Scale 列表。这样数据方面的准备工作就完成了。

```dart Scales scales(double side, Offset c, double s) => (x: _xBoxes(side, c, s), y: _yBoxes(side, c, s));

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

List _yBoxes(double side, Offset c, double s) { var (start, end) = transform(y, c.dy, s); List 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.交互过程中的数据变化

《Flutter&Flame游戏实践#16 | 生命游戏 - 编辑与交互》 一章中,我们已经实现了拖拽和移动的 Matrix 变换。所以只要在变换结束后,为 rulerValue 赋值即可。当 rulerValuetransform 数据变化时,会触发 notifyListeners 通知画板更新:

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

image.png

```dart ---->[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 进行绘制:

image.png

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


1. 画板类 RulerPainter

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

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

```dart 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 得到;

dart @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 方法绘制刻度线:

image.png

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

```dart 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 方法绘制文字。这样就完成了坐标轴的表现:

image.png

dart 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 来独立绘制坐标轴,方便统一修改或者移除:

image.png

```dart 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. 绘制网格示意

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

image.png

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

```dart 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 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 方法,将相机视口的变换矩阵重置,并偏移居中:

12.gif

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


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

image.png

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值