本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
Flutter&Flame 游戏开发系列前言:
该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台 的 原生级 渲染框架,兼具 全端
跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。
第一季:30 篇短文章,快速了解 Flame 基础。
[已完结]
第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。
两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 ,系列文章列表可在 或 查看。
在之前我们实现了两个类型的小游戏:
- Trex 跳跃碰撞类, 1~4集
- 打砖块 射击消除类,5~12集
接下来,我们将写一下 益智类 的小游戏。扫雷 作为历史悠久的一款益智游戏。在当年游戏匮乏的时代,想必它承载着很多人童年的宝贵回忆。下面几篇 Flutter&Flame 游戏实践,将像素级复刻最经典版的扫雷游戏:
一、扫雷玩法介绍
一款益智游戏,首先要明确:
[1]
. 游戏操作规则。[2]
. 游戏胜利和失败的条件。[3]
. 游戏交互细节。
1. 游戏操作规则
- 闭合的单元格中隐藏着 地雷 或 数字。
- 闭合的单元格可以通过点击打开。
- 单元格中数字表示九个中含 地雷 的数量。
比如下面的紫框中的 1
单元格,表示它所在的九格中(红框) 存在一个地雷。而红框中只有尾翻开的单元格,那么可以推理出左上角的单元格是雷:
此时就可以通过右键将该区域标记为 地雷。这就是扫雷的核心玩法:
2.游戏的胜败条件
- 当点到地雷时,游戏失败。并展示出地图中的所有地雷:
- 游戏胜利的条件是排除所有的地雷,将非雷区全部翻开:
游戏失败 | 游戏胜利 |
---|---|
总的来看,它是一个逻辑推理的益智游戏,规则非常精简,所以很容易上手。复杂的单元格也可以提高游戏的可玩性,是一个非常优秀的游戏玩法设计。
3. 游戏交互细节
下面动态图中展示了扫雷游戏的基本交互,包括:
- 按下及拖动过程中,对应的单元格处于按下状态。
- 抬起时,打开单元格。
- 右键标记、取消旗子。
- 顶部中间的表情展示当前的游戏交互状态,点击时重新开始。
- 左侧 LED 展示雷的数量,右侧 LED 展示使用的秒数。
二、整体界面布局分析
本篇我们先来解决界面设计和交互的问题,在下一章再实现具体的玩法。为了让单元格的尺寸在任何大小下都不失真,这里资源图片全部采用 svg
。也顺便介绍一下 svg 如何在 Flame 中使用。
1. 游戏界面布局
游戏界面在布局上非常简单,顶部展示游戏状态信息,一般称之为 HUD
(Heads-Up Display);下方网格是游戏区域,将作为后期处理的重点部分;除此之外,还有两者之间的边框需要展现:
整体构件结构如下图所示,SweeperLayout
负责整体布局的展示,包括外部的边线框。其中包含 SweeperHud
和 CellManager
两个主题内容作为孩子:
2. 尺寸设计
游戏中的构建尺寸如何规定,是一个棘手的问题。它是自适应屏幕宽高进行缩放,还是固定尺寸,不受窗口尺寸影响。扫雷游戏固定尺寸即可,如果自适应窗口缩放,会导致个数少时单元格非常大。我们希望窗口缩放不影响游戏的尺寸表现。
为了便于修改尺寸,游戏界面中所有的尺寸都基于一个标准尺寸作为单位。这里选取 单元格 尺寸 cellSize。当我们确定了网格的行列数 gridXY ,通过 SizeRes
类进行维护:
---->[lib/sweeper/game/config/size_res.dart]----
class SizeRes {
final double cellSize;
final (int, int) gridXY;
SizeRes({
this.cellSize = 18,
this.gridXY = (16, 16),
});
一切尺寸相关的数据够可以通过这两个数据 计算得到。比如网格区的宽高是行列数乘以单元格尺寸; Hud 尺寸高度是两个单元格大小;宽度是网格宽度。表情按钮的大小是 1.5 被的单元格大小。这里通过 get 方法提供计算逻辑:
Vector2 get gridSize => Vector2(gridXY.$1 * cellSize, gridXY.$2 * cellSize);
Vector2 get hudSize => Vector2(gridXY.$1 * cellSize, cellSize * 2);
Vector2 get faceSize => Vector2(cellSize * 1.5, cellSize * 1.5);
同样,LED 的尺寸、间隔、矩形区域等数据可以通过 get 方法提供。这也是 SizeRes
命名的原因,将其视为尺寸资源的仓库,方便统一管理和维护:
// hud 矩形区域
Rect get hudRect => Rect.fromLTWH(gap, gap, hudSize.x, hudSize.y);
// 网格矩形区域
Rect get gridRect =>
Rect.fromLTWH(gap, hudSize.y + gap + gap, gridSize.x, gridSize.y);
// 边框间隔
double get gap => 0.72 * cellSize;
// 布局总尺寸
Vector2 get layoutSize => Vector2(
gridSize.x + gap * 2,
gridSize.y + hudSize.y + gap * 3,
);
// led 宽度
double get ledWidth => cellSize * 0.64;
// led 间隔
double get ledSpace => cellSize * 0.12;
3. 阴影边线框的实现
俗话说,细节决定成败。仔细观察面板可以发现,其中有很多处阴影边线。包括最外部、单元格外围、HUD 外围、LED 灯外围四个地方。如何展示这些边框呢?
首先,这种边框存在于多个场合,所以需要封装一下便于复用。边框的展现可以通过绘制 矩形 的四条边线实现。其中可以设置边线的 边线宽度、四边颜色。如下所示,封装一个 BorderDecoration
类承载数据,调用 paint
方法,绘制对应矩形的四个边框:
---->[lib/sweeper/painter/decration/border_decroation.dart]----
class BorderDecoration {
final double strokeWidth;
final Color top;
final Color right;
final Color bottom;
final Color left;
BorderDecoration({
required this.strokeWidth,
required this.top,
required this.right,
required this.bottom,
required this.left,
});
void paint(Rect rect, Canvas canvas) {
Paint paint = Paint()..strokeWidth = strokeWidth..strokeCap = StrokeCap.round;
canvas.drawLine(
rect.topRight, rect.topRight + Offset(0, rect.height),
paint..color = right,
);
canvas.drawLine(
rect.bottomLeft, rect.bottomLeft + Offset(rect.width, 0),
paint..color = bottom,
);
canvas.drawLine(
rect.topLeft, rect.topLeft + Offset(rect.width, 0),
paint..color = top,
);
canvas.drawLine(
rect.topLeft, rect.topLeft + Offset(0, rect.height),
paint..color = left,
);
}
}
4. 实现整体布局:SweeperLayout
游戏的外框通过 SweeperLayout
构建展示,其中复写 render 方法,操作 Canvas 进行绘制边线和背景色。一共有三个边线需要绘制,分别封装为三个方法:
class SweeperLayout extends PositionComponent with HasGameRef<SweeperGame> {
@override
void onGameResize(Vector2 size) {
x = (size.x - width) / 2;
y = (size.y - height) / 2;
super.onGameResize(size);
}
@override
FutureOr<void> onLoad() {
size = game.sizeRes.layoutSize;
/// TODO... 添加内容
return super.onLoad();
}
@override
void render(Canvas canvas) {
Rect rect = Offset.zero & Size(width, height);
canvas.drawRect(rect, Paint()..color = ColorRes.background);
_pintHudBorder(canvas);
_paintGridBorder(canvas);
_paintOutBorder(canvas,rect);
super.render(canvas);
}
_pintHudBorder
: 绘制 Hud 内部的边线,创建上面写的 BorderDecoration
对象,触发 paint 方法完成绘制边线任务。
注: 其中尺寸相关的数据,封装在 game.sizeRes
中,后面会单独介绍。现在只要知道通过它可以获取尺寸数据即可:
void _pintHudBorder(Canvas canvas) {
double strokeWidth = game.sizeRes.gap * 0.35;
BorderDecoration decoration = BorderDecoration(
strokeWidth: strokeWidth,
top: ColorRes.gray,
left: ColorRes.gray,
right: ColorRes.white,
bottom: ColorRes.white,
);
decoration.paint(game.sizeRes.hudRect, canvas);
}
_paintGridBorder
用于绘制网格外围的边线:
void _paintGridBorder(Canvas canvas) {
double strokeWidth = game.sizeRes.gap * 0.42;
BorderDecoration decoration = BorderDecoration(
strokeWidth: strokeWidth,
top: ColorRes.gray,
left: ColorRes.gray,
right: ColorRes.white,
bottom: ColorRes.white,
);
decoration.paint(game.sizeRes.gridRect, canvas);
}
_paintGridBorder
用于绘制网格外围的边线:
void _paintOutBorder(Canvas canvas,Rect rect) {
double strokeWidth = game.sizeRes.gap * 0.25;
BorderDecoration decoration = BorderDecoration(
strokeWidth: strokeWidth,
top: ColorRes.white,
left: ColorRes.white,
right: ColorRes.gray,
bottom: ColorRes.gray,
);
decoration.paint(rect, canvas);
}
三、单元格与其管理器
接下来将要完成如下的单元格布局,以及拖拽时间过程中,对应单元格呈现出按压状态。其中单元格通过 svg
图片展示,这里也正好介绍一下 Flame 对 svg 的支持情况:
1. 单元格构件 Cell
这里称单元格为 Cell , 在 Flame 中使用 svg 构件,需要额外添加类库 。它是 Flame 官方基于 封装的构建:
flame_svg: ^1.10.1
我们知道 SpriteComponent
是基于 Sprite 渲染呈现内容; 这里 flame_svg 封装了 SvgComponent
构建,基于 Svg
渲染呈现内容。Sprite 基于资源图片得到,同理 Svg 可以通过加载 svg 文件得到。
同样将加载逻辑放在 TextureLoader 类中,通过 loadSvg
方法加载资源列表并放入 _svgMap
映射关系中。提供 findSvg
方法根据文件名,获取 Svg
对象:
---->[packages/flame_ext/lib/texture_loader.dart]----
final Map<String, Svg> _svgMap = {};
Future<void> loadSvg(List<String> images ,{
LoadProgressCallBack? loadingCallBack,
}) async{
int total = images.length;
int cur = 0;
for (int i = 0; i < images.length; i++) {
String filename = path.basename(images[i]);
_svgMap[filename] = await Svg.load(images[i]);
cur++;
loadingCallBack?.call(total, cur);
}
}
Svg findSvg(String name) {
if (_svgMap.containsKey(name)) {
return _svgMap[name]!;
}
throw AssetsNotFindException(name);
}
Cell 单元格如下所示 ,由于一个坐标表示它的位置;默认加载 closed.svg
文件表示闭合单元格;其中目前提供两个方法 pressed
和 reset
分别让单元格更新为按压和闭合状态。
---->[lib/sweeper/game/heros/cell/cell.dart]----
class Cell extends SvgComponent with HasGameRef<SweeperGame> {
final (int, int) pos;
Cell(this.pos);
@override
FutureOr<void> onLoad() {
double cellSize = game.sizeRes.cellSize;
size = Vector2(cellSize, cellSize);
svg = game.loader.findSvg('closed.svg');
return super.onLoad();
}
void pressed() {
svg = game.loader.findSvg('pressed.svg');
}
void reset() {
svg = game.loader.findSvg('closed.svg');
}
}
2.单元格管理器
单元格的和之前打砖块中的砖块管理类似,都是遍历行列生成单体。如下所示,在 _createCells
方法中遍历行列数,创建 Cell 对象加入列表。就可以依次排列从此网格:
---->[lib/sweeper/game/heros/cell/cell_manager.dart]----
class CellManager extends PositionComponent
with HasGameRef<SweeperGame>, DragCallbacks, GameCellLogic {
@override
FutureOr<void> onLoad() {
size = game.sizeRes.gridSize;
double gap = game.sizeRes.gap;
double height = game.sizeRes.hudSize.y + gap * 2;
position = Vector2(gap, height);
addAll(_createCells());
return super.onLoad();
}
List<Cell> _createCells() {
int rowCount = game.sizeRes.gridXY.$2;
int columnCount = game.sizeRes.gridXY.$1;
double cellSize = game.sizeRes.cellSize;
List<Cell> result = [];
for (int i = 0; i < rowCount; i++) {
for (int j = 0; j < columnCount; j++) {
Cell cell = Cell((j, i));
cell.x = cellSize * j;
cell.y = cellSize * i;
result.add(cell);
}
}
return result;
}
}
3. 交互的逻辑分离: GameCellLogic
这里做了一个有趣的尝试,将 构建构建逻辑 和 交互时数据处理逻辑 通过 mixin 进行分离。如下所示,定义了 GameCellLogic
来处理网格整体的交互逻辑:
这样就可以让逻辑更为紧凑,之后修改交互逻辑,只需要在 GameCellLogic
中处理即可。它混入 DragCallbacks ,可以复写 onDragUpdate
方法监听拖拽事件。然后根据触点和单元格尺寸计算出落点的坐标。通过 pressed
方法进行处理,将目标点的 Cell
改为 pressed 状态。最近在看 《代码简洁之道》,对我大有裨益。方法应该短小精炼,只处理必要的事:
---->[lib/sweeper/game/logic/game_cell_logic.dart]----
mixin GameCellLogic on DragCallbacks, HasGameRef<SweeperGame> {
/// 被按压的单元格
final List<Cell> _pressedCells = [];
@override
void onDragUpdate(DragUpdateEvent event) {
pressed(event.localStartPosition);;
super.onDragUpdate(event);
}
void pressed(Vector2 vector2){
game.activeFace();
double cellSize = game.sizeRes.cellSize;
int x = vector2.x ~/ cellSize;
int y = vector2.y ~/ cellSize;
pressedAt((x, y));
}
void pressedAt((int, int) pos) {
if (!_allowPressAt(pos)) return;
_resetPrevPressed();
_doPressAt(pos);
}
bool _allowPressAt((int, int) pos) {
return _pressedCells.where((e) => e.pos == pos).isEmpty;
}
void _resetPrevPressed() {
if (_pressedCells.isNotEmpty) {
Cell lastActive = _pressedCells.removeLast();
lastActive.reset();
}
}
void _doPressAt((int, int) pos) {
List<Cell> cells = children.whereType<Cell>().toList();
Iterable<Cell> targets = cells.where((e) => e.pos == pos);
if (targets.isNotEmpty) {
Cell cell = targets.first;
cell.pressed();
_pressedCells.add(cell);
}
}
}
到这里就完成了拖拽时按压的交互逻辑。将方法独立封装,可以带来很强的复用性,比如要增加点击的按下的事件时,额外混入 TapCallbacks
,复写 onTapDown
方法调用 pressed 即可:
四、HUD 的处理
HUD 中包含三个部分,左侧是地雷个数,中间是按钮,右侧时游戏开始后的秒数。
到这里,游戏中组件的整体结构就非常明确了,如下所示:
1. Led 显示屏的封装:LedScreen
这种 Led 显示屏可能在以后的项目中也能用,可以单独封装起来便于复用。如下所示,我们要封装一个显示屏,可以指定显示屏中数字管的个数,以便更灵活使用:
显示屏封装为 LedScreen
构建,传入数量、宽度、间隔信息。通过这些信息,可以计算出显示屏幕的尺寸 screenSize
。在 onLoad
方法中,遍历 count
次,加入 SvgComponent
展示数字管即可:
---->[lib/sweeper/game/heroes/hud/led_screen.dart]----
class LedScreen extends PositionComponent with HasGameRef<SweeperGame> {
final int count;
final double ledWidth;
final double ledSpace;
LedScreen({
this.count = 3,
required this.ledWidth,
required this.ledSpace,
});
Vector2 get screenSize => Vector2(
ledWidth * count + (count-1) * ledSpace+2*ledSpace,
ledWidth * 2 + ledSpace,
);
@override
FutureOr<void> onLoad() {
size = screenSize;
addAll(_createLedLamps());
return super.onLoad();
}
List<Component> _createLedLamps() {
List<Component> ledLamps = [];
Vector2 ledSize = Vector2(ledWidth, ledWidth * 2);
for (int i = 0; i < count; i++) {
SvgComponent led = SvgComponent(
svg: game.loader.findSvg('d0.svg'),
size: ledSize,
position: Vector2(ledSpace + (ledWidth + ledSpace) * i, ledSpace / 2),
);
ledLamps.add(led);
}
return ledLamps;
}
这样在 SweeperHud
中通过 _addLedScreen
方法,创建 LedScreen
添加其中即可。至于显示屏的数字变化,将在下一篇结合具体的场景来完善。
--->[lib/sweeper/game/heroes/hud/sweeper_hud.dart]----
void _addLedScreen(){
double ledWidth = game.sizeRes.ledWidth;
double ledSpace = game.sizeRes.ledSpace;
LedScreen left = LedScreen(ledSpace: ledSpace,ledWidth: ledWidth);
double ledY = (height - left.screenSize.y) / 2;
double ledX = ledWidth/2;
left.position = Vector2(ledX, ledY);
add(left);
LedScreen right = LedScreen(ledSpace: ledSpace,ledWidth: ledWidth,count: 4);
ledX = width - ledWidth/2 - right.screenSize.x;
right.position = Vector2(ledX, ledY);
add(right);
}
2. 表情按钮构件:FaceButton
表情按钮看起来非常简单,就是展示一个表情 svg 图像。通过 FaceButton
来完成,其中需要处理点击时的按压效果。混入 TapCallbacks 在按下和抬起事件中切换图片资源:
---->[lib/sweeper/game/heroes/hud/face_button.dart]----
class FaceButton extends SvgComponent with HasGameRef<SweeperGame>,TapCallbacks {
@override
FutureOr<void> onLoad() {
size = game.sizeRes.faceSize;
svg = game.loader.findSvg('face.svg');
return super.onLoad();
}
@override
void onTapDown(TapDownEvent event) {
super.onTapDown(event);
pressed();
}
@override
void onTapUp(TapUpEvent event) {
super.onTapUp(event);
reset();
}
void active(){
svg = game.loader.findSvg('face_active.svg');
}
void reset() {
svg = game.loader.findSvg('face.svg');
}
void pressed() {
svg = game.loader.findSvg('face_pressed.svg');
}
}
3. 表情按钮构件:FaceButton
虽然表情按钮非常简单,但是其中蕴含着一个很重要的知识点:如何管理表情。
如下所示,在单元格点击和拓展时,如何改变表情呢?
常规来看,想让宫格的事件影响到表情按钮,需要通过世界来一层层找到按钮对象,然后修改其图像。这样无疑非常复杂。按钮是被动地被改变,有没有什么手段能主动让按钮主动监听需要变化的事件呢?
任何构件都可以访问 Game,我们可以把它当成一个 大广播,宫格点击时发送通知。表情按钮相当于收音机,可以主动监听广播的喊话。这就是一个很标准的 监听通知机制。我们有很多种手段来完成这件事,这里先采用 Flutter 内置的 Stream 流来完成(当然你可以使用任何状态管理方式来处理)。
下面定义 GameFaceLogic
就是这个大广播,播报一个 bool 值。通过 activeFace
和 resetFace
可以让广播喊话,发送通知。SweeperGame 只要混入 GameFaceLogic
就具备了大广播的能力:
---->[lib/sweeper/game/logic/game_face_logic.dart]----
mixin GameFaceLogic{
bool _isActive = false;
final StreamController<bool> _faceCtrl = StreamController.broadcast();
Stream<bool> get faceStream => _faceCtrl.stream;
void activeFace(){
if(_isActive) return;
_faceCtrl.add(true);
_isActive = true;
}
void resetFace(){
if(!_isActive) return;
_faceCtrl.add(false);
_isActive = false;
}
}
- 单元格:广播发送消息
单元格的交互逻辑中,只需要在对应实际触发 activeFace
和 resetFace
方法,就可以发送通知。
---->[lib/sweeper/game/logic/game_cell_logic.dart]----
void pressed(Vector2 vector2){
game.activeFace();
/// 略同...
}
void unpressed(){
game.resetFace();
}
- 表情按钮:收音机接收消息
此时按钮构建在装载是监听广播,触发 _onFaceChange
方法,修改状态即可。注意在销毁时取消监听。
---->[lib/sweeper/game/heroes/hud/face_button.dart]----
StreamSubscription<bool>? _subscription;
@override
void onMount() {
super.onMount();
_subscription = game.faceStream.listen(_onFaceChange);
}
@override
void onRemove() {
_subscription?.cancel();
super.onRemove();
}
void _onFaceChange(bool value) {
if(value){
active();
}else{
reset();
}
}
到这里我们就完成了扫雷界面交互上的核心需求,下一篇将实现扫雷的具体功能逻辑,敬请期待。感谢大家的支持,喜欢的话,希望可以点个赞 ~