Flutter&Flame游戏实践#13 扫雷 - 界面

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!


Flutter&Flame 游戏开发系列前言:

该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台原生级 渲染框架,兼具 全端 跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。

第一季:30 篇短文章,快速了解 Flame 基础。[已完结]

第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。

两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》【github 项目首页】 查看。


在之前我们实现了两个类型的小游戏:

  • Trex 跳跃碰撞类, 1~4集
  • 打砖块 射击消除类,5~12集

接下来,我们将写一下 益智类 的小游戏。扫雷 作为历史悠久的一款益智游戏。在当年游戏匮乏的时代,想必它承载着很多人童年的宝贵回忆。下面几篇 Flutter&Flame 游戏实践,将像素级复刻最经典版的扫雷游戏:

image.png


一、扫雷玩法介绍

一款益智游戏,首先要明确:

  • [1]. 游戏操作规则。
  • [2]. 游戏胜利和失败的条件。
  • [3]. 游戏交互细节。

1. 游戏操作规则
  • 闭合的单元格中隐藏着 地雷数字
  • 闭合的单元格可以通过点击打开。
  • 单元格中数字表示九个中含 地雷 的数量。

比如下面的紫框中的 1 单元格,表示它所在的九格中(红框) 存在一个地雷。而红框中只有尾翻开的单元格,那么可以推理出左上角的单元格是雷:

image.png

此时就可以通过右键将该区域标记为 地雷。这就是扫雷的核心玩法:

image.png


2.游戏的胜败条件
  • 当点到地雷时,游戏失败。并展示出地图中的所有地雷:
  • 游戏胜利的条件是排除所有的地雷,将非雷区全部翻开:
游戏失败 游戏胜利
image.png image.png

总的来看,它是一个逻辑推理的益智游戏,规则非常精简,所以很容易上手。复杂的单元格也可以提高游戏的可玩性,是一个非常优秀的游戏玩法设计。


3. 游戏交互细节

下面动态图中展示了扫雷游戏的基本交互,包括:

  • 按下及拖动过程中,对应的单元格处于按下状态。
  • 抬起时,打开单元格。
  • 右键标记、取消旗子。
  • 顶部中间的表情展示当前的游戏交互状态,点击时重新开始。
  • 左侧 LED 展示雷的数量,右侧 LED 展示使用的秒数。

130.gif


二、整体界面布局分析

本篇我们先来解决界面设计和交互的问题,在下一章再实现具体的玩法。为了让单元格的尺寸在任何大小下都不失真,这里资源图片全部采用 svg。也顺便介绍一下 svg 如何在 Flame 中使用。

image.png


1. 游戏界面布局

游戏界面在布局上非常简单,顶部展示游戏状态信息,一般称之为 HUD (Heads-Up Display);下方网格是游戏区域,将作为后期处理的重点部分;除此之外,还有两者之间的边框需要展现:

image.png

整体构件结构如下图所示,SweeperLayout 负责整体布局的展示,包括外部的边线框。其中包含 SweeperHudCellManager 两个主题内容作为孩子:

image.png


2. 尺寸设计

游戏中的构建尺寸如何规定,是一个棘手的问题。它是自适应屏幕宽高进行缩放,还是固定尺寸,不受窗口尺寸影响。扫雷游戏固定尺寸即可,如果自适应窗口缩放,会导致个数少时单元格非常大。我们希望窗口缩放不影响游戏的尺寸表现。

为了便于修改尺寸,游戏界面中所有的尺寸都基于一个标准尺寸作为单位。这里选取 单元格 尺寸 cellSize。当我们确定了网格的行列数 gridXY ,通过 SizeRes 类进行维护:

1
2
3
4
5
6
7
8
9
---->[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 方法提供计算逻辑:

1
2
3
4
5
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 命名的原因,将其视为尺寸资源的仓库,方便统一管理和维护:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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 灯外围四个地方。如何展示这些边框呢?

image.png

首先,这种边框存在于多个场合,所以需要封装一下便于复用。边框的展现可以通过绘制 矩形 的四条边线实现。其中可以设置边线的 边线宽度四边颜色。如下所示,封装一个 BorderDecoration 类承载数据,调用 paint 方法,绘制对应矩形的四个边框:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
---->[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 进行绘制边线和背景色。一共有三个边线需要绘制,分别封装为三个方法:

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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 中,后面会单独介绍。现在只要知道通过它可以获取尺寸数据即可:

image.png

1
2
3
4
5
6
7
8
9
10
11
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 用于绘制网格外围的边线:

image.png

1
2
3
4
5
6
7
8
9
10
11
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 用于绘制网格外围的边线:

image.png

1
2
3
4
5
6
7
8
9
10
11
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 的支持情况:

131.gif


1. 单元格构件 Cell

这里称单元格为 Cell , 在 Flame 中使用 svg 构件,需要额外添加类库 flame_svg。它是 Flame 官方基于 flutter_svg 封装的构建:

1
flame_svg: ^1.10.1

我们知道 SpriteComponent 是基于 Sprite 渲染呈现内容; 这里 flame_svg 封装了 SvgComponent 构建,基于 Svg 渲染呈现内容。Sprite 基于资源图片得到,同理 Svg 可以通过加载 svg 文件得到。

同样将加载逻辑放在 TextureLoader 类中,通过 loadSvg 方法加载资源列表并放入 _svgMap 映射关系中。提供 findSvg 方法根据文件名,获取 Svg 对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
---->[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 文件表示闭合单元格;其中目前提供两个方法 pressedreset 分别让单元格更新为按压和闭合状态。

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
---->[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 对象加入列表。就可以依次排列从此网格:

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
---->[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 来处理网格整体的交互逻辑:

image.png

这样就可以让逻辑更为紧凑,之后修改交互逻辑,只需要在 GameCellLogic 中处理即可。它混入 DragCallbacks ,可以复写 onDragUpdate 方法监听拖拽事件。然后根据触点和单元格尺寸计算出落点的坐标。通过 pressed 方法进行处理,将目标点的 Cell 改为 pressed 状态。最近在看 《代码简洁之道》,对我大有裨益。方法应该短小精炼,只处理必要的事:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
---->[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 即可:

image.png


四、HUD 的处理

HUD 中包含三个部分,左侧是地雷个数,中间是按钮,右侧时游戏开始后的秒数。

image.png

到这里,游戏中组件的整体结构就非常明确了,如下所示:

image.png


1. Led 显示屏的封装:LedScreen

这种 Led 显示屏可能在以后的项目中也能用,可以单独封装起来便于复用。如下所示,我们要封装一个显示屏,可以指定显示屏中数字管的个数,以便更灵活使用:

image.png

显示屏封装为 LedScreen 构建,传入数量、宽度、间隔信息。通过这些信息,可以计算出显示屏幕的尺寸 screenSize。在 onLoad 方法中,遍历 count 次,加入 SvgComponent 展示数字管即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
---->[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 添加其中即可。至于显示屏的数字变化,将在下一篇结合具体的场景来完善。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
--->[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 在按下和抬起事件中切换图片资源:

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
---->[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

虽然表情按钮非常简单,但是其中蕴含着一个很重要的知识点:如何管理表情

如下所示,在单元格点击和拓展时,如何改变表情呢?

133.gif

常规来看,想让宫格的事件影响到表情按钮,需要通过世界来一层层找到按钮对象,然后修改其图像。这样无疑非常复杂。按钮是被动地被改变,有没有什么手段能主动让按钮主动监听需要变化的事件呢?

fbb39c4a7030a30465f4548b3b91399.png


任何构件都可以访问 Game,我们可以把它当成一个 大广播,宫格点击时发送通知。表情按钮相当于收音机,可以主动监听广播的喊话。这就是一个很标准的 监听通知机制。我们有很多种手段来完成这件事,这里先采用 Flutter 内置的 Stream 流来完成(当然你可以使用任何状态管理方式来处理)。

image.png


下面定义 GameFaceLogic 就是这个大广播,播报一个 bool 值。通过 activeFaceresetFace 可以让广播喊话,发送通知。SweeperGame 只要混入 GameFaceLogic 就具备了大广播的能力:

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
---->[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;
}
}
  • 单元格:广播发送消息

单元格的交互逻辑中,只需要在对应实际触发 activeFaceresetFace 方法,就可以发送通知。

1
2
3
4
5
6
7
8
9
---->[lib/sweeper/game/logic/game_cell_logic.dart]----
void pressed(Vector2 vector2){
game.activeFace();
/// 略同...
}

void unpressed(){
game.resetFace();
}
  • 表情按钮:收音机接收消息

此时按钮构建在装载是监听广播,触发 _onFaceChange 方法,修改状态即可。注意在销毁时取消监听。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
 ---->[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();
}
}

到这里我们就完成了扫雷界面交互上的核心需求,下一篇将实现扫雷的具体功能逻辑,敬请期待。感谢大家的支持,喜欢的话,希望可以点个赞 ~

133.gif

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%