theme: cyanosis
本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
Flutter\&Flame 游戏开发系列前言:
该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台 的 原生级 渲染框架,兼具 全端
跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。
第一季:30 篇短文章,快速了解 Flame 基础。
[已完结]
\ 第二季:从休闲游戏实践,进阶 Flutter\&Flame 游戏开发。
两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》 或 【github 项目首页】 查看。
在空无一物的黑色空间之中,没有标志,没有参考,没有依靠,充满着未知的恐惧。
他说:固定一个标点吧 ,于是世界就诞生了。
世界因此有了位置,有了区域,有了形状,有了万物。在生命游戏的世界之中,需要一把尺子,度量世界的样貌。
一、以有限模拟无限
在二维空间中,有横纵两个纬度。视口区域是世界的展示范围。这个范围对应着横坐标范围和纵坐标范围。 在世界的无限空间之内,视口仅是微不足道的一部分。但视口可以进行拖拽
和缩放
,来展示其他空间的内容。
这就让 有限的视口区域 ,有了展示 无限空间 的可行性。
本篇源码详见: 【tolygame/modules/lifegame/lib/03】
1. 什么是二维无限标尺
如下所示,当原点固定,我们就可以在世界中建立坐标系来描述世界的位置信息。其中缩放和移动可以调节视口中展示区域的内容。你可以向上下左右无限
地拖拽,展示世界的内容。
比如你向右移动,最右侧达到了 100 亿,并不会让网格或说世界从 0
一直绘制到 100亿
。而是:
100亿 - 屏幕容量 ~ 100 亿
也就是说,无论你移动到哪里,都 只会渲染屏幕区域,这就是保证无限空间下,内存和性能的关键。这也是 生命游戏 无限空间可行性的保证。本篇我们将着重探索这个 二维无限标尺
的实现方式。
2.实现思路
这个功能最难的地方在于:如何计算区域范围随手势交互的变换。
比如红色区域是 x 轴,实际刻度范围在 [0,size.width] 。这里设单位格长为 40,根据变换矩阵计算出刻度横坐标区间 -10,10
。
如果向左移动 40,则横坐标区间变为 -9,11
,以此类推。同理,缩放也是如此,至于缩放时,如何根据缩放中心对尺寸区域进行变换,在 《编程与数学 | 一维空间的中心缩放》 一文中已经详细地介绍过了。
总得来说,就是根据移动和缩放的矩阵变换,来动态计算区域对应的坐标刻度范围;有了范围之后,就可以遍历刻度值,进行绘制操作。
二、数据层逻辑处理
首先来思考一下,当前功能需求中需要哪些数据。
- 操作时的矩阵变换数据 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(重新绘制) 的驱动力,如下所示:
2. 刻度数据 Scale
这里称每个宫格对应的坐标为刻度 Scale
, 包括横纵两种形式,使用 Axis
进行区分;如下所示, Scale
还包含数值、主轴尺寸两个属性:
```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
赋值即可。当 rulerValue
的 transform
数据变化时,会触发 notifyListeners
通知画板更新:
而 Flame 中视口的变换通过相机的 Transform2D
,它是一个可监听对象。所以可以监听它的变化,来为 rulerValue
赋值。如果不是玩 Flame 的朋友,可以参考直接通过 rulerValue
的变换矩阵去操作画布变换:
```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
进行绘制:
···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
方法绘制刻度线:
横向的刻度线,需要根据变化中心的横坐标进行偏移,而且这个偏移不应该影响后续的绘制。可以使用 save 和 restore 这对操作。绘制的核心逻辑交由 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
方法绘制文字。这样就完成了坐标轴的表现:
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 来独立绘制坐标轴,方便统一修改或者移除:
```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. 绘制网格示意
网格对于坐标系来说意义重大,它在视觉上划分出空间与格点;但本质来看,网格只不过是刻度尺的附庸。二维空间的形成并不是因为有网格,而是因为有刻度的规范。
所以网格也就是刻度线拉长了而已,我们可以像绘制刻度尺那样绘制网格,代码如下:
```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 方法,将相机视口的变换矩阵重置,并偏移居中:
dart void fit() { camera.viewfinder.transform.transformMatrix=Matrix4.translationValues(size.x/2, size.y/2, 0); }
到这里,维的无限标尺就完成了。有了无限标尺,等价于我们可以绘制无限的空间,从而让生命游戏可以有无尽的空间进行生存。只渲染视口范围的内容,也为内存和性能提供了保证。如下所示,将标尺的边长和之前的生命游戏宫格一致。就可以让之前的案例在坐标系统之中。下一章,我们将实现无限空间的生命游戏,敬请期待 ~