本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
Flutter&Flame 游戏开发系列前言:
该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台 的 原生级 渲染框架,兼具 全端
跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。
第一季:30 篇短文章,快速了解 Flame 基础。
[已完结]
第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。
两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》 或 【github 项目首页】 查看。
一、从固定分辨率认识相机
设备屏幕的尺寸可谓千差万别,而且桌面端的应用窗口可以调整大小,这就使得游戏随尺寸变化变得复杂。对于某些游戏来说,希望是 分辨率恒定 。比如打砖块高度如果自适应屏幕,很小时就无法体验。
1. 固定分辨率的优势
下面的应用窗口不同,游戏场景的 比例保存不变
;同时会根据分辨率进行 缩放
,在界面中保持合适的位置。游戏场景之外的灰色区域可以做些背景点缀,这是一种解决游戏场景尺寸的方案。
如下所示,黑色区域代表游戏场景,蓝色是游戏之外的背景。可以看到在变动应用窗口尺寸时:
- [1]. 游戏场景的长宽比保持不变。
- [2]. 游戏场景的内容会随尺寸变更而缩放。
- [3]. 在游戏场景缩放过程中,场景内的世界坐标保持不变。
2. 相机 Camera 和世界 World
我们知道 Flame 中的一切都是 Component
。在 FlameGame 中,存在两个 "开天辟地"
时就创建的两个角色 - 相机 Camera 和 世界 World 。下面是 FlameGame 的源码:
- 在构造时可以传入 world 和 camera 两个参数。
- 当未传入时,会创建并添加默认的世界和相机。
- FlameGame 支持一个 World 派生类的泛型。
游戏主类在继承 FlameGame 时可以声明世界的泛型类型,比如这里自定义 PlayWord
。设置相机可以通过 super 构造传递世界和相机,这里通过 CameraComponent.withFixedResolution
构造创建指定分辨率的相机,宽高为 320*480
:
dart
---->[lib/world/13/main.dart]----
class MainGame extends FlameGame<PlayWord> {
MainGame() : super(
world: PlayWord(),
camera: CameraComponent.withFixedResolution(width: 320, height: 480),
);
late final Image spriteImage;
@override
Future<void> onLoad() async {
spriteImage = await Flame.images.load('demos/c17_mini_rmbg.png');
}
@override
Color backgroundColor() => const Color(0xff5EC8F8);
}
自定义 PlayWord
类作为游戏的世界,这里绘制了一个左上角坐标 (0,0)
、宽高为 80 的黑色矩形。从这里可以看出: 世界的原点在视口中心。
dart
class PlayWord extends World {
@override
void render(Canvas canvas) {
super.render(canvas);
canvas.drawRect(const Rect.fromLTWH(0, 0, 80, 80), Paint());
}
}
此时,将矩形的左顶点坐标改为 (-160,-240)
, 宽高改为 320*480
,就可以将视口区域着成黑色:
dart
---->[lib/world/13/main.dart]----
class PlayWord extends World {
@override
void render(Canvas canvas) {
super.render(canvas);
canvas.drawRect(const Rect.fromLTWH(-160, -240, 320, 480), Paint());
}
}
这样一个固定分辨率的视口就完成了,当拖拽改变应用窗口尺寸,视口区域就能保证分辨率不变,等比缩放了。
3. 世界中的角色
下面定义 Hero 展示一个魔法师图片, 将其加入到 PlayWord
世界中。可以看到其左上角放置在世界的中心,因为默认情况下构件的锚点在左上角:
dart
class PlayWord extends World {
@override
FutureOr<void> onLoad() {
add(Hero());
// 略同...
}
---->[lib/world/13/main.dart]----
class Hero extends SpriteComponent with HasGameRef<GameWorld> {
@override
FutureOr<void> onLoad() {
sprite = Sprite(game.spriteImage);
debugMode = true;
debugColor = Colors.white;
return super.onLoad();
}
}
如果想让角色的锚点在中心,可以将 anchor
设置为 Anchor.center
。这样角色的锚点将放置在世界原点,如下所示:
dart
class PlayWord extends World {
@override
FutureOr<void> onLoad() {
add(Hero()..anchor=Anchor.center);
// 略同...
}
二、认识 World 世界
世界和相机
构件是 FlameGame 的成员属性,是游戏主场景开始时的组成部分。在 Trex 游戏中,我们直接将角色放入到游戏主场景中,并没有使用到这两个构件。也就是说之,前我们在场景中添加的构件(下面灰色),相当于世界和相机的兄弟:
1. World 构件的源码
World 是一个 Component ,使用当使用世界时,相当于我们写的构件被添加到了 World 之下。
从源码的角度来看,World 只是一个非常非常普通的 Component。当两个构件有重合时,优先级高的会展示在上方。World 的优先级被设置为 -0x7fffffff
,也就是说 一切角色在世界之上。
dart
class World extends Component implements CoordinateTransform {
World({
super.children,
super.priority = -0x7fffffff,
});
@override
void renderTree(Canvas canvas) {}
@internal
void renderFromCamera(Canvas canvas) {
assert(CameraComponent.currentCamera != null);
super.renderTree(canvas);
}
@override
bool containsLocalPoint(Vector2 point) => true;
@override
Vector2? localToParent(Vector2 point) => null;
@override
Vector2? parentToLocal(Vector2 point) => null;
}
另外,World 实现了 CoordinateTransform
接口,该接口定义了 父坐标和本地坐标
之间转换的两个方法。在 World 中这两个方法返回 null, 表示 World 构件在逻辑上是顶层,没有必要进行坐标转换。
dart
abstract class CoordinateTransform {
Vector2? parentToLocal(Vector2 point);
Vector2? localToParent(Vector2 point);
}
这就是 Flame 中 World 的全部代码,本身并没有复杂的逻辑。所以也不用太畏惧这个宏大的 数据概念,就是一个在最底下的空白构件而已。
2. 绘制世界坐标系
之前一个角色站在空荡荡的世界里,说世界的原点在视口中心,这句话大家可能也品味不出其中的价值。其实认识世界,最重要的是要理解 世界坐标系 。在现实世界中的含义就是 我在哪里 。接下来我就把世界坐标系画出来给你看,让你直观感受这个世界的存在。下面是一个 480*320
的世界:
- 黑色区域是世界的范围。
- 红色轴是世界坐标轴,两轴的交点是世界的原点。
- 格线是间的距离是 20,白色文字标注了轴线的距离原点的位置。
坐标系的绘制过程,属于绘制知识,它只是一个视觉上的辅助,我将绘制逻辑封装为 WorldGrid 。这里就不专门介绍了,感兴趣的可以自己看一下源码实现,【world/14/world_grid.dart】
dart
---->[lib/world/14/main.dart]----
class PlayWord extends World {
// 略同...
final WorldGrid _worldGrid = WorldGrid(
axisColor: Colors.red,
textColor: Colors.white,
gridColor: const Color(0xff4AFFFF),
);
@override
void render(Canvas canvas) {
// 略同...
_worldGrid.paint(canvas, kViewPort);
}
}
对于固定分辨率的视口,当尺寸变化时,世界本身会 等比缩放 。如下所示,当世界缩小时,黑色区域在 逻辑上
依然是 480*320
,因此固定分辨率的相机创建的是一个不受屏幕真实尺寸影响的 世界。
3. World 和 Camera 的关系
想一下,这个不受屏幕真实尺寸影响的 世界 ,是谁的功劳? World 本身并没有干什么事,那剩下的参与者有且仅有 Camera。 那 World 和 Camera 是什么关系呢? 在 FlameGame 源码中,创建 Camera
之后,会将 _world
赋值给 _camera.world
这说明 Camera 持有 World 成员,这也是相机可以影响世界的原因。对于世界的认知就到这里,下面我们来初步认识这个非常重要的 Camera 角色。
三、相机对世界的变换
在介绍相机之前,先举个小场景引入一下:
将一张红色的塑料膜放在眼前,你将看到一个红色的世界。我们当然知道红膜本身并没有改变世界的能力,它改变的只是观察者的视觉呈现。
相机也是一样,它只是操作对画板 Canvas 的进行变换,改变对世界的观察角度。
1. 缩放变换
界面中有三种基础变换 移动
、缩放
、旋转
。在 【world/14】 中,给出了操作世界变化的案例。如下,通过 [
和 ]
来放大和缩小世界的表现:
世界的变换效果,由 Camera 中的 Viewfinder
构件所决定。它本质上是维护 Matrix4 变换矩阵、在渲染时将变换施加到 Canvas 之上。为了使用方便 Viewfinder 中封装了简单的变换操作:通过 zoom
属性的控制缩放变换:
dart
// 每次按下 ] 放大 0.1;
camera.viewfinder.zoom += 0.1;
// 每次按下 ] 缩小 0.1;
double newZoom = camera.viewfinder.zoom-0.1;
camera.viewfinder.zoom = max(0.1, newZoom);
2. 移动变换
如下所示,通过键盘的上下左右按键控制移动变换,视觉上是世界在移动。世界的移动和缩放,都不会超出视口的范围,也就是这里世界的显示区域,永远在 480*320
之内,超过的区域不会渲染:
和 zoom 类似,Viewfinder 这封装了 position 设置的方法更新世界的位置 :
dart
camera.viewfinder.position -= Vector2(0, 10); //上
camera.viewfinder.position += Vector2(0, 10); //下
camera.viewfinder.position += Vector2(10, 0); //左
camera.viewfinder.position -= Vector2(10, 0); //右
3. 旋转变化
同理对于旋转变换,Viewfinder 这封装了 angle 设置的方法修改世界的旋转角度 :
dart
camera.viewfinder.angle-= 2*(pi/180);
4. Viewfinder 变换的本质
稍微瞄一眼 Viewfinder 中的这三个变换的方法,可以看出他们都是通过 transfrom
成员实现的功能。
它的类型是 Transform2D,是 Flame 中对 matrix 变换矩阵的封装体。继承自 ChangeNotifier
说明其具有通知监听的能力:
dart
/// Transform matrix used by the viewfinder.
final Transform2D transform = Transform2D();
可以再向下深追一点, CameraComponent 渲染时,会先通过 Viewfinder 的变换矩阵,对 Canvas 进行操作,再渲染世界。这里就像是: 在眼前放一张变换能力的塑料膜,让你看到世界之前先施加变换
。
到这里,我们已经对 CameraComponent 进行有一个基本的了解。相机还有其他的特性,在后期用到时会继续介绍。对于打砖块游戏,了解到这里就够用了。
五、将打砖块改为固定分辨率
按照前面固定分辨率的方案,可以在视口区域展示游戏世界,这样应用层窗口的尺寸变化,只是对视口的缩放,从而可以保证游戏区域在 任何尺寸窗口 下表现的一致性。
- | - | - |
---|---|---|
2. BricksGame 代码处理
这里先暂定砖块供 9 列,每个砖块宽 64;游戏区域 1080:2400
的长宽比,该固定的尺寸定义为全局常量 kViewBox 。然后在 super 构造中指定相机和 PlayWorld 世界,
dart
---->[lib/bricks/03/bricks_game.dart#BricksGame]----
const Size kViewPort = Size(64 * 9, 64 * 9 * 2400 / 1080);
class BricksGame extends FlameGame<PlayWorld> with KeyboardEvents, HasCollisionDetection {
BricksGame()
: super(
camera: CameraComponent.withFixedResolution(
width: kViewPort.width,
height: kViewPort.height,
),
world: PlayWorld(),
);
由于视口默认以中心为原点,之前是以左上角为原点放置打砖块内容的。可以通过设置 camera.viewfinder
的锚点,来确定世界坐标系原点的位置, Anchor.topLeft
表示以左上角为原点:
dart
TextureLoader loader = TextureLoader();
@override
FutureOr<void> onLoad() async {
await loader.load(
'assets/images/break_bricks/break_bricks.json',
'break_bricks/break_bricks.png',
);
camera.viewfinder.anchor = Anchor.topLeft;
}
3. PlayWorld 的代码处理
接下来,只要把之前放在游戏中的角色,放入到 PlayWorld 中即可。点击和拖拽事件也可以放入其中:
dart
---->[lib/bricks/03/bricks_game.dart#PlayWorld]----
class PlayWorld extends World with HasGameRef<BricksGame>, DragCallbacks, HasCollisionDetection, TapCallbacks {
Ball ball = Ball();
Paddle paddle = Paddle();
BrickManager brickManager = BrickManager();
@override
FutureOr<void> onLoad() async {
super.onLoad();
add(Playground());
add(paddle);
add(ball);
add(brickManager);
}
@override
void onTapDown(TapDownEvent event) {
ball.run();
}
@override
void onDragUpdate(DragUpdateEvent event) {
super.onDragUpdate(event);
double dx = event.localDelta.x;
double max = kViewPort.width - paddle.width;
paddle.x = (paddle.x + dx).clamp(0, max);
}
}
另外由于键盘事件只能在 FlameGame
中混入,无法放到 PlayWorld 中处理。键盘控制挡板移动的逻辑需要放到 BricksGame
中,通过 world 成员可以访问 paddle :
csharp
---->[lib/bricks/03/bricks_game.dart#BricksGame]----
@override
KeyEventResult onKeyEvent(KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
super.onKeyEvent(event, keysPressed);
if (event is KeyDownEvent || event is KeyRepeatEvent) {
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.paddle.moveBy(-moveStep);
case LogicalKeyboardKey.arrowRight:
world.paddle.moveBy(moveStep);
}
}
return KeyEventResult.handled;
}
本篇小结
到这里,游戏的视口就优化完毕了。即使把窗口缩小到非常小,也不会影响游戏的功能。
本篇主要介绍了相机 Camera 和 世界 World 相关的概念,知识点不是很多,但非常重要。
我们目前完成了最核心的游戏逻辑功能,距离一个完整的游戏还有大距离。接下来对整体 UI 进行一些优化,以整体游戏的视角进行完善,比如游戏中的场景、关卡、道具、菜单、音效等方面。敬请期待 ~