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