前言
最近在开发一个App 项目时,里面有一个许愿瓶的功能里面有很多小球,为了让他更加逼真,我决定加入重力感应的功能。为了实现它开启了我的学习Flame之旅。虽然以前有用Cocos开发游戏的经验,对游戏开发的基本概念有所了解,但由于时间久远且两者存在差异,我觉得有必要重新学习。
坐标系统是每个游戏引擎的基础。掌握坐标系统不仅能让我们更好地控制游戏画面中物体的布局,还能帮助我们更好地适配不同设备。
在这篇文章中,我将尝试分享我对Flame和Forge2d坐标系统的理解。也许这些内容对于游戏开发新手会有些帮助。如果您也对这个话题感兴趣,不妨我们一起探索一下坐标系统。
Flame 是什么?
Flame 是一个模块化的 Flutter 游戏引擎,为游戏提供了一套完整的开箱即用的解决方案。它利用了 Flutter 提供的强大基础设施,但简化了您构建项目所需的代码。
它为您提供了简单而有效的游戏循环实现,以及游戏中可能需要的必要功能。例如:输入、图像、精灵、精灵表、动画、碰撞检测,以及我们称之为 Flame 组件系统(简称 FCS)的组件系统。
Forge2d是什么?
如果开发过游戏应该对Box2d 有所了解,Box2D是一款免费的开源二维物理引擎,由Erin Catto使用C++编写,在zlib授权下发布。Forge2d 是Box2D 物理引擎的移植版本,可以在flutter中直接使用2D 引擎。
在了解Forge2d 物理引擎的坐标系统之前,我们先来了解一下 Flame 的基础概念
GameWidget
在Flutter 中一切皆为Widget,Flame 也不例外,GameWidget 是flutter中一个特殊的组件,这个组件内部用于渲染游戏内容。我们可以将其理解为画布,类似与Html中的canvas
Component (Flame)
所有组件都继承自抽象类Component,并且所有组件都可以将其他组件作为子组件。这是我们所称的Flame组件系统(简称FCS)的基础。
子组件可以通过add(Component c)方法添加,也可以直接在构造函数中添加。
Flame中的Component 类似于Flutter 中的Widget,游戏内容就是由一个个Component组成的。
FlameGame
FlameGame 是游戏世界的根节点,用于管理整个游戏,处理渲染,世界的创建,游戏世界的销毁,处理游戏世界的全局逻辑等。
World
World 组件是挂载在FlameGame下面的组件,Flame 提供了方便的机制可以在Component 内部很轻易的访问World,这样我们就可以轻松的操作整个游戏中的其他任何组件
Camera
这是一个特殊的组件,它决定了如何呈现整个游戏内容,有了它,我们可以更好的对游戏中的内容进行建模,接下来我们要讨论的坐标系统和他密不可分
通常情况下一个游戏的组件结构如下:
world 和 camera是必须的而且如果创建了flameGame 就会默认创建,当然你也可以手动创建
游戏内容通常是挂载在world 下方
当然你想挂载到flameGame下方也是可以的,通常是一些不会随着camera 变化而变化的控制面板人物的血条等。
一个简单的案例
在游戏世界中添加一个红色的200x200的矩形
import 'dart:async';
import 'package:flame/camera.dart';
import 'package:flame/game.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import 'package:flame/input.dart';
void main() {
runApp(Center(
child: Container(
padding: EdgeInsets.all(10),
color: Colors.white,
child: GameWidget(game: MyGame()),
),
));
}
class MyGame extends FlameGame with TapDetector {
late final SquareComponent square;
@override
Color backgroundColor() {
// 添加灰色,用于区分画布区域
return Colors.grey;
}
@override
Future<void> onLoad() async {
await super.onLoad();
world.add(square);
}
}
// 红色的矩形,用于测试
class SquareComponent extends RectangleComponent {
SquareComponent() : super(paint: Paint()..color = Colors.red);
@override
FutureOr<void> onLoad() {
add(TextComponent(
text: '$size',
textRenderer:
TextPaint(style: TextStyle(color: Colors.black, fontSize: 40)))
..anchor = Anchor.center
..x = size.x / 2
..y = size.y / 2);
return super.onLoad();
}
}
Iphone XR中展示如下
Ipad 中展示如下
如果是做网页开发可能这并没有什么问题,但是在游戏开发中元素(人物,道具等)的大小通常是相对于屏幕大小是等比缩放的。当然我们可以手动计算这些元素相对于标准屏幕的百分比然后通过当前设备与标准屏幕的比例,给每一个元素乘以一定的比例,但是这样做增加了开发者的负担,在整个过程中需要始终关心这个缩放比例。
Flame 给我们提供了一个更加便捷的方案,就是相机取景器,你可以自己定义整个画面的分辨率,这里的分辨率和屏幕的分辨率没有关系,就像是一张图片的分辨率和当前展示图片的屏幕并没有关系
Flame 相机的取景器提供了以下几种策略决定当前游戏的虚拟分辨力:
MaxViewport
(default) – 此视区将扩展到允许的最大大小 by the game,即它将等于 Game Canvas 的大小。这种情况属于默认情况,游戏的分辨率和画布的分辨率一直,也就是说不同设备的分辨率可能会有较大的差异FixedResolutionViewport
– 保持分辨率和纵横比固定,黑条位于 两边(左右或者上下) 如果它与纵横比不匹配。这种取景器的好处是,在游戏世界中使用固定的分辨率,如果取景器的分辨率和画布的分辨率不一致就会出现黑边,类似于Html 中 img 适配的contain 模式。FixedSizeViewport
– 具有预定义大小的简单矩形视口。FixedAspectRatioViewport
– 一个矩形视口,可扩展以适应 拖动到游戏画布中,但保留其纵横比。CircularViewport
– 圆形的视口,固定大小。
比如说上述案例,我们可以将取机器的分辨率改成750*1336,然后看看效果
IPHONE SE
Ipad
那我们在游戏中布局时无需关心那台设备,物体的大小都是相同的,无需频繁的换算
相机下还会挂载一个对象叫做**取景器(**viewfinder)对象
取景器
取景器的属性允许您指定哪个点 内部视口充当摄像机的“逻辑中心”。例如 在横向卷轴动作游戏中,通常将镜头聚焦在 主角不在屏幕中央显示,但更靠近 左下角。这个偏离中心的位置将是 “逻辑中心” 由取景器的 .anchoranchor
如果您将子项添加到他们 将出现在前面 世界,但在视区后面,并且具有与 应用于世界,因此这些组件不是静态的。Viewfinder
您还可以将行为组件作为子项添加到取景器中,以便 示例效果器或其他控制器。例如,如果您添加 a,您将能够在游戏中实现平滑缩放。
如果我们将viewfinder 的缩放比例设置为2,效果如下
小结
游戏世界中的像素是虚拟像素,和设备的像素没有必然的关系,但是为了保持好的比例,我们要将虚拟像素和真实像素找到一个合适的缩放比例,这些可以通过Camera的Viewport 实现,缩放不仅可以通过
如果 虚拟像素和物理像素的比例是 r
1 flamePx = zoom * r devicePx
Forge2d
接下来我们引入物理引擎Forge2d,前面已经引入了Forge2d,在物理世界中物体的宽高比采用的是米,物理引擎既然是模拟物理世界的,那在物理引擎中距离单位也是米,接下来我们采用Forge2d给世界中添加一个正方形
import 'dart:async';
import 'package:flame/camera.dart';
import 'package:flame/game.dart';
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
void main() {
runApp(Center(
child: Container(
padding: const EdgeInsets.all(10),
color: Colors.white,
child: GameWidget(game: MyGame()),
),
));
}
class MyGame extends Forge2DGame {
static var designSize = Vector2(750, 1336); // 设计分辨率
late final SquareComponent square;
@override
Color backgroundColor() {
// TODO: implement backgroundColor
return Colors.grey;
}
@override
Future<void> onLoad() async {
await super.onLoad();
camera.viewport = FixedResolutionViewport(resolution: designSize);
// camera.viewfinder.zoom = 1;
square = SquareComponent()
..position = Vector2(0, 0) // 居中放置
..size = Vector2(100, 100)
..anchor = Anchor.center;
world.add(square);
}
}
class SquareComponent extends RectangleComponent {
SquareComponent() : super(paint: Paint()..color = Colors.red);
@override
FutureOr<void> onLoad() {
add(TextComponent(
text: '$size',
textRenderer: TextPaint(
style: const TextStyle(color: Colors.black, fontSize: 10)))
..anchor = Anchor.center
..x = size.x / 2
..y = size.y / 2);
return super.onLoad();
}
}
我们会发现 100x100 的元素超出了我们的画面,那是因为默认情况下
camera.viewfinder.zoom 被设置为 10,100*100 在flame中会被渲染成 1000x1000 ,自然会超出画面。
物理引擎和画布
在物理模拟中,我们通常以米为标准单位。然而,当我们需要在画布上表示一个1000m的物理世界时,这种scale可能不太适合模拟较小的物体,比如瓶子。为了解决这个问题,我们需要建立物理引擎单位与现实世界单位之间的对应关系。
关键在于调整物理引擎中的力的单位。例如,如果我们希望以厘米(cm)为标准单位,那么重力加速度应该调整为9.8 * 100 cm/s²,即980 cm/s²。这种调整使得物理引擎中的1单位长度对应现实世界中的1厘米。
基于这种转换,我们可以推导出游戏世界、物理世界和画布之间的比例关系。假设:
- vm 代表游戏世界的单位
- n 代表重力加速度的倍数(例如,如果使用厘米,n = 100)
- zoom 代表设备的缩放比例
那么,我们可以得出以下关系式:
💡1 vm = 1/n m = zoom px
这个公式表明:
- 游戏世界的1单位(vm)等于物理世界的1/n米
- 游戏世界的1单位(vm)在画布上表现为zoom像素
通过这种方法,我们可以在保持物理模拟精确性的同时,灵活地调整游戏世界的scale,以适应不同大小的物体和不同分辨率的设备。这种转换机制对于跨平台游戏开发和精确的物理模拟至关重要。
总结
有了上面这个公式我们就可以轻而易举的在物理引擎中 对现实中的世界建立对应的模型,在后续开发的过程中能够更加直观,希望这篇博客能够在学习Flame起步的过程中帮助到你,如果有帮助的话欢迎关注,后续还会分享更多关于自己在学习过程中的总结。
附件
最后在来一张全局的图,用于展示flame 和forge中 组件通常的嵌套关系,其中大写表示Class,全小写表示属性