本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
Flutter&Flame 游戏开发系列前言:
该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台 的 原生级 渲染框架,兼具 全端
跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。
第一季:30 篇短文章,快速了解 Flame 基础。
[已完结]
第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。
两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》 或 【github 项目首页】 查看。
上一篇实现了贪吃蛇的基本逻辑,包括蛇的移动控制、食物的获取以及游戏结束的校验。本文将进一步完善游戏功能,包括:
- 支持分数、速度、时间等信息的展示。
- 游戏暂停和结束界面示意,以及重新开始。
- 支持界面中多个食物,并且食物标有颜色和分数。
- 支持双人对战。
如下所示,每次吃到食物时,会将身体的这一格染成对应颜色,并且分数值增加食物的数值,每当分数 +100,速度将会增加 1 倍;右上角展示游戏进行的时长。下方中间的文字可以示意游戏暂行
、运行中
、死亡
的状态。
双人对战效果如下,目前处理的比较简单。支持两个玩家在同一台设备上,控制各自的一条蛇;其中一条通过 wasd
字母键,另一条通过 上下左右
键控制,效果如下所示:
一、头部和底部信息展示
游戏界面在布局上非常简单,顶部展示游戏状态信息,一般称之为 HUD
(Heads-Up Display);目前包含三个部分:
- 游戏得分 : ScoreHud
- 游戏速度 : SpeedHud
- 游戏时间 : TimeHud
底部包含玩家信息,游戏状态,以及一个设置文字(暂时无用)。
1. Hud 构件
在布局上,我们可以定义 Hud 构建,包含上面三个子构件。并承担监听数据变化,通知子构件。Hud
可以和 Ground
同级,放在游戏世界的上方:
class Hud extends PositionComponent with HasGameRef<SnakeGame> {
@override
void onGameResize(Vector2 size) {
this.size = Vector2(size.x, 46);
super.onGameResize(size);
}
ScoreHud score = ScoreHud();
SpeedHud speed = SpeedHud();
TimeHud time = TimeHud();
@override
FutureOr<void> onLoad() {
add(score);
add(speed);
add(time);
return super.onLoad();
}
///TODO 监听信息变化,更新子构件
}
2. 分数子构件:ScoreHud
分数构件中只要渲染一个 TextComponent, 它居左 20 且竖直方向居中。在 onParentResize 回调中可以感知父级的尺寸,在这里对文字构件进行定位;最后提供 setScore
方法设置构件的分数数值:
class ScoreHud extends PositionComponent {
TextStyle style = const TextStyle(
fontSize: 16,
fontFamily: 'BlackOpsOne',
package: 'life_game',
color: Color(0xff00ffff),
);
late TextComponent text = TextComponent(
text: 'Score: 0',
textRenderer: TextPaint(style: style),
);
void setScore(int value) {
text.text = 'Score: $value';
}
@override
void onParentResize(Vector2 maxSize) {
text.position = Vector2(20, (maxSize.y - text.height) / 2);
super.onParentResize(maxSize);
}
@override
FutureOr<void> onLoad() {
add(text);
return super.onLoad();
}
}
另外两个子构件内容上是类似的,只不过 onParentResize 方法中对文字的定位不同罢了。这里就不赘述了。可详见源码。
3. 底部信息 ToolBar
底部栏和头部栏的结构类似,最后将它们加入到 SnakeGame 游戏场景中:
class ToolBar extends PositionComponent with HasGameRef<SnakeGame> {
@override
void onGameResize(Vector2 size) {
this.size = Vector2(size.x,46);
y = game.size.y-46;
super.onGameResize(size);
}
PlayerBar player = PlayerBar();
PlayerStatusBar status = PlayerStatusBar();
Settings setting = Settings();
@override
FutureOr<void> onLoad() {
add(player);
add(status);
add(setting);
return super.onLoad();
}
// TODO 监听游戏状态变化
}
二、游戏状态数据的维护
游戏的状态数据就是在游戏过程中,所有可能变化而影响渲染表现的数据。比如这里的 分数、速度、时间,甚至是蛇、食物所依赖的数据。它们都会在游戏的过程中发生变化。对于游戏而言,我们要清晰地认识到:
游戏状态数据有哪些,它们会在何时发生怎样的变化。
1. 游戏中依赖的数据
下表中总结了一下当前游戏中依赖的数据,并定义一个 GameStateMixin 统一维护这些数据的变化:
数据名 | 类型 | 介绍 |
---|---|---|
蛇坐标队列(snakeList) | Queue<Point<int> | 渲染蛇内容 |
食物坐标列表(foodList) | List<Point<int> | 渲染食物内容 |
游戏状态(status) | GameStatus | 标识并展示游戏状态 |
得分(score) | int | 标识并展示游戏状态 |
速度(speed) | Speed | 移动速度等级 |
时长(duration) | int | 一局游戏运行时长 |
mixin GameStateMixin {
Queue<SnakeNode> snakeList = Queue();
List<Food> foodList = [];
GameStatus _status = GameStatus.ready;
int _score = 0;
Speed _speed = Speed.initial;
double _duration = 0;
//...
上一章中游戏数据重要维护在 WorldRender 中,这里将数据迁移之后,只要混入 GameStateMixin 就可以向右这些数据:
2. 状态数据变化的发生通知
现在想要细粒度地监听数据的变化,通知界面中的构件更新信息。这里采用 Stream 监听通过的方式来维护数据更新通知。在 《Flutter&Flame游戏实践#13 | 扫雷 - 界面交互》 一文中对这种方式进行过介绍,这里将进一步拓展使用的方式。如下所示,定义 GameEvent
表示游戏过程中的数据变化的事件:
sealed class GameEvent {}
然后基于 GameEvent 派生出各个数据的变化事件。基于这些事件,可以精确传递某个数据变化的具体时机:
class ScoreChangeEvent extends GameEvent {
final int score;
ScoreChangeEvent(this.score);
}
class TimeChangeEvent extends GameEvent {
final int duration;
TimeChangeEvent(this.duration);
}
class SpeedChangeEvent extends GameEvent {
final int speed;
SpeedChangeEvent(this.speed);
}
class StatusChangeEvent extends GameEvent {
final GameStatus status;
StatusChangeEvent(this.status);
}
监听通知机制将依赖于 Stream 机制,如下所示 GameStateMixin
中创建 GameEvent
泛型的 StreamController
。定义了 emit
方法产出元素,通知监听者:
mixin GameStateMixin {
//...
final StreamController<GameEvent> _controller = StreamController.broadcast();
Stream<GameEvent> get stream => _controller.stream;
void emit(GameEvent event) {
_controller.add(event);
}
void dispose() {
_controller.close();
}
比如对于分数而言,可以提供 set
方法,在其中通过 emit
方法,产出 ScoreChangeEvent 事件:
int _score = 0;
int get score => _score;
set score(int value) {
_score = value;
setSpeed((value~/100)+1);
emit(ScoreChangeEvent(_score));
}
3.监听事件触发
有了事件流,我们就可以在 Hud 构件中监听流。如下所示,在 onMount
挂在时监听 stream
; 在构件被移除时,通过 _subscription?.cancel
取消监听。这样在 _onHudChange
回调中,就可以监听到数值变化的回调时机,通过 GameEvent
中数据,就可以为子构件设置最新值:
class Hud extends PositionComponent with HasGameRef<SnakeGame> {
StreamSubscription<GameEvent>? _subscription ;
//略同...
@override
void onMount() {
_subscription = game.stream.listen(_onHudChange);
super.onMount();
}
@override
void onRemove() {
_subscription?.cancel();
}
void _onHudChange(GameEvent event) {
if(event is ScoreChangeEvent){
score.setScore(event.score);
}
if(event is SpeedChangeEvent){
speed.setSpeed(event.speed);
}
if(event is TimeChangeEvent){
time.setScore(event.duration);
}
}
}
通过 Stream 机制,实现了数据变化和界面更新的解耦合。数据的变化将统一在 GameStateMixin 中处理。比如游戏时间,可以在每帧更新时累加时间差 dt,当新旧的秒数不同时,产出 TimeChangeEvent 元素,来通知 Hud 进行更新时间:
double _duration = 0;
void tickFrameUpdate(double dt) {
int second = _duration.floor();
_duration += dt;
int newSecond = _duration.floor();
if (newSecond != second) {
emit(TimeChangeEvent(newSecond));
}
}
速度的变化检验入参的 level
,如何相同则不做处理。不同时,根据 Speed 支持的范围,更新 _speed
的值;在产出 SpeedChangeEvent 元素,来通知 Hud 进行速度:
void setSpeed(int level) {
if(speed.level==level) return;
var (min: min, max: max) = Speed.limit;
int newSpeed = level.clamp(min, max);
_speed = Speed.kSupports.firstWhere((e) => e.level == newSpeed);
emit(SpeedChangeEvent(_speed.level));
}
对于游戏状态 GameStatus 也是类似,你如果有其他可能独立变化的数据,也可以通过拓展 GameEvent 承载数据,并在数据变化时产出。
三、食物和蛇颜色的处理
当蛇和食物需要支持颜色,就表示它们每个节点的数据已经不再是简单坐标值,而需要添加额外的属性。
1. 节点数据的增强
现在蛇的数据需要增加颜色值,定义如下的 SnakeNode 节点。此时状态数据中就需要使用 Queue<SnakeNode>
队列维护蛇的数据:
class SnakeNode {
final Point<int> position;
Color? color;
SnakeNode({
required this.position,
this.color,
});
}
食物增加了颜色值和分数值,定义如下的 SnakeNode 节点维护。此时状态数据中就需要使用 List<SnakeNode>
列表维护食物的数据:
class FoodNode {
final Color color;
final Point<int> position;
final int score;
FoodNode({
required this.color,
required this.position,
required this.score,
});
}
2. 碰撞逻辑的修改
由于蛇和食物已经不再是 Point<int>
列表,在 move 方法中找到下一帧的坐标,我们需要通过 indexWhere
来得到对应坐标的食物。如果不等于 -1
,说明碰到了食物,需要增加对应颜色的节点。由于移动时是将尾节点移除,所以需要通过 updateSnakeAttr
方法,将所有属性向前平移一位:
void move(Direction direction) {
/// 略同...
int foodIndex = foodList.indexWhere((e) => e.position == newHead);
updateSnakeAttr();
if (foodIndex != -1) {
FoodNode food = foodList.removeAt(foodIndex);
snakeList.addFirst(SnakeNode(position: newHead));
snakeList.last.color = food.color;
onEatFood(food);
createFoods(1);
} else {
snakeList.addFirst(SnakeNode(position: newHead));
snakeList.removeLast();
}
}
/// 将后元素的属性,赋值给前一元素
void updateSnakeAttr() {
List<SnakeNode> snake = snakeList.toList();
for (int i = 0; i < snake.length-1; i++) {
snake[i].color = snake[i + 1].color;
}
}
另外,小蛇的自己的碰撞逻辑也需要进行修改:
bool _checkAlive(Point<int> head) {
bool selfLoop = snakeList.skip(1).where((e) => e.position == head).isNotEmpty;
bool outRange = head.x < 0 || head.y < 0 || head.x >= column || head.y >= row;
return !(outRange || selfLoop);
}
3. 创建小蛇和食物
Snake 创建时指定 initCount 个元素,以及初始时的坐标位置。通过 for 循环,像 snakeList 添加元素:
void createSnake() {
snakeList.clear();
int initCount = 3;
int initX = column ~/ 2;
int initY = row ~/ 2;
for (int i = 0; i < initCount; i++) {
Point<int> position = Point<int>(initX, initY + i);
snakeList.add(SnakeNode(position: position));
}
}
创建食物的方法也进行了升级,支持创建 count 个食物。核心逻辑还是和之前一样。先得到当前空间中空闲坐标列表,在随机取出一个作为食物位置。在 FoodNode 创建时,可以随机设置颜色和分数值:
void createFoods(int count) {
Random random = Random();
Set<Point> exist = {...snakeList.map((e) => e.position), ...foodList.map((e) => e.position)};
Set<Point<int>> foodPositionPool = {};
for (int i = 0; i < column; i++) {
for (int j = 0; j < row; j++) {
foodPositionPool.add((Point(i, j)));
}
}
foodPositionPool = foodPositionPool.difference(exist);
for (int i = 0; i < count; i++) {
Point<int> position = foodPositionPool.toList()[random.nextInt(foodPositionPool.length)];
foodList.add(
FoodNode(
color: kColorSupport[random.nextInt(kColorSupport.length)],
position: position,
score: 20 + random.nextInt(40)),
);
foodPositionPool.remove(position);
}
onFoodChange(foodList.first);
}
四、双人对战的支持
想要支持多人控制多条蛇,就需要对数据进行再度升级。每个玩家都应该有与之对应的 SnakeNode 节点队列;每个玩家也都需要记录其操作的方向键。
1. 玩家类 Player
如下所示,定义 Player 玩家类,其中包括名称和颜色用于区分。除此之外,持有 Queue<SnakeNode>
和 Direction
成员。然后将之前 GameStateMixin
中的 Queue<SnakeNode>
升级为 Player
列表:
- 通过
checkDirection
方法校验是否允许该方向的操作,允许时更新 lastDirection; - reset 方法,在指定点位,初始化队列中的内容,用于游戏重新开始的处理。
class Player {
final String name;
final Color color;
Player({
required this.name,
required this.color,
});
Queue<SnakeNode> snakeList = Queue();
Direction lastDirection = Direction.up;
void reset(Point<int> point,{int count = 3}){
snakeList.clear();
lastDirection = Direction.up;
for (int i = 0; i < count; i++) {
Point<int> position = Point<int>(point.x, point.y+ i);
snakeList.add(SnakeNode(position: position));
}
}
bool checkDirection(Direction direction) {
bool allow = lastDirection.opposite != direction;
if (allow) {
lastDirection = direction;
}
return allow;
}
}
2. 方向控制的代码处理
首先,对应一个 DirectionType 枚举用于表示方向控制的类型。arrow 和 wasd 分别代表方向键和字母键;然后将 onDirectionChange
接口增加 DirectionType 类型的参数:
enum DirectionType{
arrow,
wasd
}
abstract class GameOperation {
void onDirectionChange(Direction direction,DirectionType type);
然后在 DirectionCtrlMixin 中,键盘事件中再校验 wasd 字母键,触发 onDirectionChange
回调即可。在游戏主类中,监听到方向键的变化时,根据类型处理不同玩家的方向校验:
mixin DirectionCtrlMixin on KeyboardEvents implements GameOperation {
@override
KeyEventResult onKeyEvent(KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
if (event is KeyDownEvent) {
// ...
Direction? direction2 = calcDirection2(keysPressed);
if(direction2!=null){
onDirectionChange(direction2,DirectionType.wasd);
}
// ...
}
Direction? calcDirection2(Set<LogicalKeyboardKey> keysPressed) {
final isArrowDown = keysPressed.contains(LogicalKeyboardKey.keyS);
if (isArrowDown) return Direction.down;
final isArrowLeft = keysPressed.contains(LogicalKeyboardKey.keyA);
if (isArrowLeft) return Direction.left;
final isArrowUp = keysPressed.contains(LogicalKeyboardKey.keyW);
if (isArrowUp) return Direction.up;
final isArrowRight = keysPressed.contains(LogicalKeyboardKey.keyD);
if (isArrowRight) return Direction.right;
return null;
}
3. 数据层的处理
最后,在数据层面 WorldRender#tickRender 中需要遍历 Player 列表,处理每个玩家对应小蛇的 move 以及校验游戏胜负结果。其中 move 方法可以复用之前的逻辑;
---->[WorldRender]----
void tickRender() {
if (status != GameStatus.playing) return;
int cur = DateTime.now().millisecondsSinceEpoch;
bool timeSkip = cur - _timeRecord < speed.time;
if (timeSkip) return;
for (int i = 0; i < players.length; i++) { // ++++
Player player = players[i]; // ++++
move(player, player.lastDirection); // ++++
} // ++++
_checkPlayerAttack(); // ++++
onSnakeChange();
_timeRecord = cur;
}
_checkPlayerAttack
方法用于校验不同玩家的碰撞检测,其中逻辑是: 当一个玩家的蛇头碰到任意一个玩家的蛇节点时,被判为输:
void _checkPlayerAttack() {
List<String> lossPlayer = [];
for (Player player in players) {
List<Player> others = players.where((e) => e != player).toList();
SnakeNode head = player.snakeList.first;
for (Player other in others) {
if (other.snakeList.map((e) => e.position).contains(head.position)) {
lossPlayer.add(player.name);
}
}
}
if(lossPlayer.isNotEmpty){
onDied('${lossPlayer.join(',')} Loss!');
}
}
4. 视图层逻辑处理
视图层的逻辑处理主要在两个地方: Snake 构件 和 PlayerBar 构件, 它们都由一个变成多个,需要根据玩家数遍历添加:
如下所示,在 Ground#updateSnake
中,遍历 game.players 添加 Snake 构件。其中 Snake 需要增加一个颜色参数表示蛇头的颜色,来区分玩家:
---->[Ground]----
void updateSnake() {
removeWhere((e) => e is Snake);
for (int i = 0; i < game.players.length; i++) {
final Player player = game.players[i];
add(Snake(player.snakeList, player.color));
}
}
底部栏 ToolBar 的处理也是类似,遍历玩家添加 PlayerBar,指定对应的名称和颜色:
---->[ToolBar]----
@override
FutureOr<void> onLoad() {
double offsetX = 0;
for (Player player in game.players) {
PlayerBar bar = PlayerBar(player.name, player.color);
add(bar..x = bar.position.x + offsetX);
offsetX += bar.width + 16;
}
到这里,双人对战版的贪吃蛇就基本实现了,不过目前只要一个玩家输了,就直接终止游戏,处理的比较粗暴。你也可以自己优化一下逻辑,比如一个玩家死亡时,散落为食物什么的,增强游戏可玩性。另外,如果方格看起来感觉不太美观,你可以在方块中放置图片来优化视觉表现,那本章就到这里,谢谢观看 ~