本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
Flutter&Flame 游戏开发系列前言:
该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台 的 原生级 渲染框架,兼具 全端
跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。
第一季:30 篇短文章,快速了解 Flame 基础。
[已完结]
第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。
两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》 或 【github 项目首页】 查看。
通过上一篇的 《二维无限标尺》 我们已经完成了 无限空间 的理论基础。这一篇,将结合生命游戏,完成无限空间的功能。最近正值巴黎奥运期间,下面以完成版的生命游戏为中国健儿加油 ~
本章代码地址: github.com/toly1994328…
一、以有限拟无限
无论多么快的计算机,无论多么高效的编程语言,对于真正的无限来说都是螳臂当车。当要渲染十万、百万、甚至上亿的点时。将数据一次性加入内存,每帧全部绘制一遍是及其粗劣的。此时算法策略将对性能产生绝对的影响,硬着头皮画 1 亿个点,那应用卡死是必然的。
其实在日常开发中,我们经常会遇到一维的无限,比如短视频、新闻、博客等 app ,它们的内容总量数以亿计。但是对于用户来说,并不需要一次性将所有的数据请求。因为用户无法在某一时刻消费所有数据
,所以有种交互策略叫做 加载更多。
掘金 | B站 |
---|---|
对于 1 亿个点的绘制也是类似,一亿个点全部展示在屏幕上,已经超出了屏幕所能展示的能力。用户在其视口之内一定时有限的,有限数据
+ 交互加载
就可以通过有限模拟出无限。而对于当前的 生命游戏 来说,这是二维的无限,包含宽高两个维度的无限,要比一维的列表展示要复杂很多。
二、从无限尺到无限空间
度量的标尺是决定空间存在的要素,建立坐标系之后,就可以通过坐标数据对点进行标记、绘制渲染。这样,视图的展示就等价于坐标数据的获取:比如下面坐标系中的白块展示了 中 字,就是收集对应坐标信息,在对应坐标中绘制白块:
当向左滑动时
:右侧点的数据将被加入画面,从而展示出 中国:
随着 视口的缩小
: 会有更多的点进入可视区域,比如下面的 中国奥运 加油!。对于缩放来说,需要限制最小缩放值,来避免海量的点被同时绘制。比如下面最小缩放值时,将会有近 7000 的点。
这些看似只是白点,点在生命游戏中,是具有属性的空间,可以打开上帝视角,查看一下每个空间的拥挤程度:
随着 视口的放大
: 可视区域的点数将急剧减少,比如下面就可以只加载和渲染 200 多个点,让你的计算机松口气:
到这里,大家应该理解了通过优限来模拟无限的思路。根据这个思路,其实可以完成很多重要的功能,比如说真正意义上的无限画板、无限坐标系中的函数图像绘制等。下面就让我们一起看看,在代码中如何实现这个酷酷的无限二维空间吧。
三、数据是决定界面渲染的关键
在二维无限尺中,有两组非常关键的数据:当前视口中区域横纵刻度范围。 如下所示,在当前变化操作下,横坐标在 6 ~ 48
; 纵坐标在 42~63
。这两组数据是我们将色块加入游戏空间的关键:
现在将这四个数据定义为 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) 和之前最大的不同在于:生存的空间并不是指定的行列数,而是可以随演化而变换的区域。如下所示,第一帧中细胞存在的范围如红框所示,也就是说,演化时只需要计算红框中的数据即可:
在演化过程中,生存空间是在实时变化的,有可能逐渐变小,也可能不断扩张:
现在有个数学问题,已知一个坐标列表,如何得到坐标所在区域的最大值和最小值。代码如下所示,当前空间中存活的坐标由 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 也是在绘制前进行变换的:
2. Flame 中使用 TransformableMixin
对于 Flame 的 Camrea 来说,变换矩阵的维护者是其 Viewfinder#Transform2D
中的 _transformMatrix
成员:
所以可以继续派生一个 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
回调,在其中通知世界进行重新渲染。其实很好理解,因为渲染的是视口区域的空间,那么移动和缩放必然会引发其他空间变得可视,所以需要重新渲染:
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 组件进行实践监听:
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);
}
到这里,生命游戏的无限空间的核心代码就介绍完毕了,更多的细节方面,大家可以自己查阅源码。下一篇,我们将继续完善生命游戏,基于本地数据库,实现绘制的的空间可存储;另外有了本地数据库,就可以收录 生命游戏 世界图书馆,从而更便于体验与交流生命游戏,敬请期待~