Flutter Flame 实现接龙纸牌游戏教程(二):骨架搭建
在本节中,我们将概述游戏的主要元素,包括主游戏类和总体布局。
KlondikeGame
在Flame框架中,FlameGame
类是大多数游戏的基石。此类负责运行游戏循环、分派事件、拥有构成游戏的所有组件(组件树),通常还作为游戏状态的中央存储库。
所以,在 lib\
文件夹中创建一个名为klondike_game.dart
的新文件,并在其中声明KlondikeGame
类:
import 'package:flame/game.dart';
import 'package:flame/flame.dart';
class KlondikeGame extends FlameGame {
@override
Future<void> onLoad() async {
await Flame.images.load('klondike-sprites.png');
}
}
现在我们只声明了 onLoad
方法,这是一个特殊的处理器,当游戏实例首次附加到 Flutter 小部件树时会调用它。你可以将其视为一个延迟的异步构造函数。当前,onLoad
方法的唯一功能是将精灵图像加载到游戏中;但我们很快会添加更多功能。任何你希望在游戏中使用的图像或其他资源都需要首先加载,这个过程是一个相对较慢的 I/O 操作,因此需要使用 await
关键字。
我将图像加载到全局的 Flame.images
缓存中。另一种方法是将其加载到 Game.images
缓存中,但那样就比较难从其他类中访问该图像了。
另外请注意,我在初始化游戏中的其他内容之前等待图像加载完成。这是为了方便:这样一来,当所有其他组件初始化时,它们可以假设精灵表已经加载完毕。我们甚至可以添加一个辅助函数来从公共精灵表中提取精灵:
Sprite klondikeSprite(double x, double y, double width, double height) {
return Sprite(
Flame.images.fromCache('klondike-sprites.png'),
srcPosition: Vector2(x, y),
srcSize: Vector2(width, height),
);
}
这个辅助函数在本章中不会用到,但是在下一章会被广泛使用。
为了将这个类合理地集成到项目中,使其不再孤立无援,打开 main.dart
文件,找到写有 final game = FlameGame();
的那一行,将 FlameGame
替换为 KlondikeGame
。你还需要导入这个类。完成后的文件应该如下所示:
import 'package:flame/game.dart';
import 'package:flutter/widgets.dart';
import 'klondike_game.dart';
void main() {
final game = KlondikeGame();
runApp(GameWidget(game: game));
}
其他类
目前我们已经有了主要的 KlondikeGame
类,现在我们需要创建一些将要添加到游戏中的对象。在 Flame 中,这些对象被称为组件(components),当它们被添加到游戏中时,会形成一个“游戏组件树”。所有在游戏中存在的实体都必须是组件。
正如我们在上一章中提到的,我们的游戏主要由 Card
组件组成。但是,由于绘制卡片需要一些努力,我们将把该类的实现推迟到下一章。
现在,让我们按照草图创建容器类。这些类是:Stock
、Waste
、Pile
和 Foundation
。在 lib/
文件夹中创建一个子目录 components
,然后创建文件 lib/components/stock.dart
。 在该文件中编写以下内容:
import 'package:flame/components.dart';
class Stock extends PositionComponent {
@override
bool get debugMode => true;
}
在这里,我们将 Stock
类声明为一个 PositionComponent
(一个具有位置和大小的组件)。我们还为这个类启用了调试模式,这样即使我们还没有任何渲染逻辑,也能在屏幕上看到它。
同样地,创建另外三个类 Foundation
、Pile
和 Waste
,每个类放在其对应的文件中。目前,这四个类的逻辑完全相同,我们将在后续章节中为这些类添加更多功能。
此时,你的游戏目录结构应该如下所示:
klondike/
├─assets/
│ └─images/
│ └─klondike-sprites.png
├─lib/
│ ├─components/
│ │ ├─foundation.dart
│ │ ├─pile.dart
│ │ ├─stock.dart
│ │ └─waste.dart
│ ├─klondike_game.dart
│ └─main.dart
├─analysis_options.yaml
└─pubspec.yaml
游戏结构
一旦我们有了一些基本的组件,就需要将它们添加到游戏中。现在是时候决定游戏的高层结构了。
这里有多种不同的方法,它们在复杂性、可扩展性和总体理念上有所不同。本教程中我们将采用的方法是使用 World
组件和 Camera
组件。
这种方法背后的理念是:设想你的游戏世界独立于设备而存在,它已经存在于我们的脑海中和草图上,即使我们还没有编写任何代码。这个世界将有一定的大小,世界中的每个元素将有固定的坐标。由我们来决定这个世界的大小,以及尺寸的度量单位。重要的一点是,这个世界独立于设备存在,它的尺寸也不受屏幕分辨率的影响。
所有属于世界的元素都将被添加到 World
组件中,然后再将 World
组件添加到游戏中。
整体结构的第二部分是一个摄像机(CameraComponent
)。摄像机的目的是能够查看世界,并确保它在用户设备的屏幕上以正确的尺寸渲染。
因此,组件树的整体结构大致如下所示:
KlondikeGame
├─ World
│ ├─ Stock
│ ├─ Waste
│ ├─ Foundation (×4)
│ └─ Pile (×7)
└─ CameraComponent
针对这个游戏,我在绘制图像资源时考虑到了单张牌的尺寸为 1000×1400 像素。因此,这将作为确定整体布局的参考尺寸。另一个影响布局的重要测量值是牌与牌之间的距离。这个距离应在 150 到 200 个单位之间(相对于牌的宽度),因此我们将其声明为一个可调整的变量 cardGap
。为简化起见,牌之间的垂直和水平距离将相同,并且牌与屏幕边缘之间的最小间距也将等于 cardGap
。
好了,让我们将这些内容整合起来,来实现我们的 KlondikeGame
类。
首先,我们声明几个全局常量来描述一张牌的尺寸和牌与牌之间的距离。我们将它们声明为常量,因为我们不打算在游戏期间改变这些值:
static const double cardWidth = 1000.0;
static const double cardHeight = 1400.0;
static const double cardGap = 175.0;
static const double cardRadius = 100.0;
static final Vector2 cardSize = Vector2(cardWidth, cardHeight);
接下来,我们将创建一个 Stock
组件、一个 Waste
组件、四个 Foundation
组件和七个 Pile
组件,并在世界中设置它们的尺寸和位置。这些位置将通过简单的算术计算得出。这些操作都应该在 onLoad
方法中完成,在加载了精灵图之后:
final stock = Stock()
..size = cardSize
..position = Vector2(cardGap, cardGap);
final waste = Waste()
..size = cardSize
..position = Vector2(cardWidth + 2 * cardGap, cardGap);
final foundations = List.generate(
4,
(i) => Foundation()
..size = cardSize
..position =
Vector2((i + 3) * (cardWidth + cardGap) + cardGap, cardGap),
);
final piles = List.generate(
7,
(i) => Pile()
..size = cardSize
..position = Vector2(
cardGap + i * (cardWidth + cardGap),
cardHeight + 2 * cardGap,
),
);
从 Flame 1.9.0 版本开始,FlameGame
默认会设置 world
和 camera
对象。KlondikeGame
是 FlameGame
的扩展,所以我们可以直接将所有组件添加到这个默认的 world
中。这样可以简化我们的代码。
world.add(stock);
world.add(waste);
world.addAll(foundations);
world.addAll(piles);
注意事项
你可能会想知道在什么时候需要等待
add()
方法的结果,什么时候不需要。简短的答案是:通常你不需要等待,但是如果你想等待也没有问题。如果你查看
add()
方法的文档,你会看到返回的Future
仅仅等待组件加载完成,而不是等到它实际挂载到游戏中。因此,只有在你的逻辑要求组件在完全加载后才能继续操作时,你才需要等待add()
方法的Future
。这种情况并不常见。如果你不等待
add()
方法的Future
,组件也会被添加到游戏中,并且所需的时间相同。
最后一步,我们需要使用 FlameGame 的camera
对象来查看world
。相机内部由两个部分组成:视口(viewport)和取景器(viewfinder)。默认的视口是 MaxViewport
,它占用整个可用屏幕的大小,这正是我们需要的,所以无需做任何更改。而取景器需要根据底层世界的尺寸进行设置。
我们希望整个纸牌布局在屏幕上可见,无需滚动。为此,我们需要指定整个世界的尺寸(即 7*cardWidth + 8*cardGap
的宽度和 4*cardHeight + 3*cardGap
的高度)能够适应屏幕。通过 .visibleGameSize
设置可以确保无论设备的大小如何,都会调整缩放级别,使指定的游戏世界部分可见。
游戏大小计算:
-
宽度:桌上有7张牌和6个间隙,加上两侧的额外间隙,共计
7 * cardWidth + 8 * cardGap
。 -
高度:垂直方向上有两排牌,但底排需要一些额外的空间来显示高堆,大致估算三倍牌的高度,即总高度为
4 * cardHeight + 3 * cardGap
。
此外,我们需要指定在视口的“中心”应该是哪部分世界。在本例中,“中心”应该位于屏幕的顶部中心,对应游戏世界中的坐标为 [(7 * cardWidth + 8 * cardGap) / 2, 0]
。
这种取景器位置和锚点的选择,是因为我们希望在游戏尺寸变得过宽或过高时,内容能合适响应。如果过宽,内容需要在屏幕上居中;如果过高,内容需要顶部对齐。
camera.viewfinder.visibleGameSize =
Vector2(cardWidth * 7 + cardGap * 8, 4 * cardHeight + 3 * cardGap);
camera.viewfinder.position = Vector2(cardWidth * 3.5 + cardGap * 4, 0);
camera.viewfinder.anchor = Anchor.topCenter;
我们的基础游戏结构已经创建完成,接下来的一切将基于这个结构。在下一步中,我们将学习如何渲染纸牌对象,这些是游戏中最重要的视觉对象。
教程翻译自 Flame 官方文档:Klondike 教程
转载自:https://juejin.cn/post/7372586171917156389