本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
Flutter&Flame 游戏开发系列前言:
该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台 的 原生级 渲染框架,兼具 全端
跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。
第一季:30 篇短文章,快速了解 Flame 基础。
[已完结]
第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。
两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》 或 【github 项目首页】 查看。
在计算机兴起的几十年来,有一些非常经典的休闲小游戏。经久不衰,它们通常规则简单,非常适合作为入门游戏的练习项目。接下来的几篇中,通过介绍一些经典小游戏的实现。第一位是贪吃蛇。它的规则非常简单:
一条小小的蛇在空间中不断觅食而增长。在碰壁或者缠绕时,游戏失败。
一、 贪吃蛇数据与交互分析
贪吃蛇其实和之前做的 生命游戏 是非常类似的,都是宫格中的点集运算与展示。我们首先分析一下,贪吃蛇游戏中,数据和交互操作的关系。
1. 数据结构分析
贪吃蛇本质是宫格中的点集列表,每个点表示横纵坐标位置,它决定宫格中需要渲染的格点位置。下面可以开启上帝视角,展示一下每个宫格的坐标值:其中
- 蓝色+白色代表蛇,蓝色是蛇头,白色是蛇身。这里的蛇由 (10, 8)、(10, 9)、(10, 10) 三个坐标构成。
- 红色代表食物。此时的坐标为 (6,6) 点。
考虑到希望可以在宫格中生成多个随机的食物,所以这里蛇和食物的核心数据都是格点列表。考虑到后期蛇的身体会频繁地增删节点,可以考虑使用 Queue 双端队列。食物诞生后就固定了,所以类型可以选用 List<Point<int>>
:
Queue<Point<int>> snakeQueue = Queue.of([Point(10, 8), Point(10, 9), Point(10, 10)]);
List<Point<int>> foodList = [Point(6, 6)];
1. 向上移动时
现在分析一下,让小蛇向上平移时,对应底层的数据是如何变化的。如下所示,每次点击 ↑
按钮时小蛇将向上移动一格。很容易想到,可以将所有的小蛇节点 纵坐标 -1
,但这并不是最佳方案。
仔细分析一下可以发现,我们只需要在蛇头的上方增加一格作为新头,然后将尾部的节点移除。这样就可以保证蛇的总长不变,所有格点纵坐标向上平移一位。
这就是使用 双端队列 的优势,它可以在 O(1) 的时间复杂度下处理首尾元素的操作,而让所有节点平移是 O(n) 的复杂度。如果蛇的身体非常长,这种算法就有很大的优势。
2. 向右移动
同理,向右移动,就是先找到 下一帧的头结点 坐标,将其入栈;再将尾节点出栈。如下所示:
- 第一帧的头部是 (10,8),第二帧向右移动,那么新头将是 (11,8)
- 再移除尾部节点,就可以得到第二帧的小蛇数据,触发渲染即可。
3.根据方向更新下一帧数据
小蛇运动的方向有上下左右四个,可数尽的元素可以通过枚举表示。如下定义 Direction:
enum Direction {
up,
down,
left,
right;
}
根据上下左右可以预测下一帧头部坐标,然后添头去尾即可。由下面的 move 方法 对数据进行处理,效果如下所示:
void move(Direction direction) {
Point<int> oldHead = snakeList.first;
Point<int> newHead = switch (direction) {
Direction.up => Point(oldHead.x, oldHead.y - 1),
Direction.down => Point(oldHead.x, oldHead.y + 1),
Direction.left => Point(oldHead.x - 1, oldHead.y),
Direction.right => Point(oldHead.x + 1, oldHead.y),
};
snakeList.addFirst(newHead);
snakeList.removeLast();
setFrame();
}
4.方向控制的封装
任何应用产品都是在交互过程中,引发数据的变化,再通过更新界面,给用户正确的视觉反馈。在贪吃蛇游戏中,交互主要表现在控制上下左右的移动事件。
在整个应用交互过程中,引发同类事件的入口可能非常多。比如这里控制方向可能通过 上下左右键盘
,也可能通过触摸滑动,也可能通过界面上的模拟键盘触发。这些入口希望可以统一去维护,可以提供一个接口,比如下面 DirectionChange
的抽象方法 onDirectionChange 可以感知方向的变化:
abstract class DirectionChange{
void onDirectionChange(Direction direction);
}
由于贪吃蛇规则中,头部碰到其他身体部分视为缠绕,而游戏结束。所以运行中的贪吃蛇只有三个可选择的方向,可以记录上一次的运行方向,然后忽略反向的按键即可。比如当前运行方向向下,那么向上的操作需要本禁止:
于是,可以写出下面的 DirectionCtrlMixin
全权负责方向变化的触发逻辑。此时在 SnakeGame 中,就可以通过混入 DirectionCtrlMixin
实现 DirectionChange
接口来感知 onDirectionChange 的变化,处理逻辑即可:
mixin DirectionCtrlMixin on KeyboardEvents implements DirectionChange {
Direction lastDirection = Direction.up;
@override
KeyEventResult onKeyEvent(KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
if (event is KeyDownEvent) {
Direction? direction = calcDirection(keysPressed);
tickDirectionChange(direction);
}
return super.onKeyEvent(event, keysPressed);
}
void tickDirectionChange(Direction? direction){
bool allow = direction != null && lastDirection?.opposite != direction;
if (allow) {
onDirectionChange(direction);
lastDirection = direction;
}
}
Direction? calcDirection(Set<LogicalKeyboardKey> keysPressed) {
final isArrowDown = keysPressed.contains(LogicalKeyboardKey.arrowDown);
if (isArrowDown) return Direction.down;
final isArrowLeft = keysPressed.contains(LogicalKeyboardKey.arrowLeft);
if (isArrowLeft) return Direction.left;
final isArrowUp = keysPressed.contains(LogicalKeyboardKey.arrowUp);
if (isArrowUp) return Direction.up;
final isArrowRight = keysPressed.contains(LogicalKeyboardKey.arrowRight);
if (isArrowRight) return Direction.right;
return null;
}
}
二、数据的维护与界面渲染
下面来看一下如何维护数据,并在数据变化时,触发世界内容的重新渲染。并实现贪吃蛇沿着当前方向不断继续运动的功能。游戏一般都会有很多配置项,这里通过 Configable
来记录和维护:
mixin Configable {
int column = 24;
int row = 14;
double boxSize = 24;
Speed _speed = Speed.initial;
Speed get speed => _speed;
}
其中 Speed 和之前生命游戏时类似的,每帧间有固定的触发间隔,并且支持速度的变化。其中 kSupports
表示支持的速度等级:
class Speed extends Equatable {
final double level;
static const _kWorldTimeUnit = 600;
const Speed._(this.level);
static List<Speed> kSupports = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0].map((e) => Speed._(e)).toList();
static Speed initial = const Speed._(1);
double get time => _kWorldTimeUnit / level;
@override
List<Object?> get props => [level];
}
1.游戏数据总指挥
游戏数据该放在哪里,是一个比较头疼的问题。当前最重要的两个数据就是蛇的身体 snakeList
和 食物列表 foodList
。现在将它们作为 WorldRender
的成员,当 SnakeGame 混入了它,就拥有了访问和操作数据的能力:
mixin WorldRender{
Queue<Point<int>> snakeList = Queue.of([const Point(10, 8), const Point(10, 9), const Point(10, 10)]);
List<Point<int>> foodList = [const Point(6, 6)];
void move(Direction direction) {
Point<int> oldHead = snakeList.first;
Point<int> newHead = switch (direction) {
Direction.up => Point(oldHead.x, oldHead.y - 1),
Direction.down => Point(oldHead.x, oldHead.y + 1),
Direction.left => Point(oldHead.x - 1, oldHead.y),
Direction.right => Point(oldHead.x + 1, oldHead.y),
};
snakeList.addFirst(newHead);
snakeList.removeLast();
}
}
并非每次游戏 update 都需要更新世界的数据,可以通过 speed
来校验两次触发的时间差。小于阈值时,不进行处理即可,这一点和生命游戏也是类似的。另外,提供了 onSnakeChange
回调,让外界感知蛇数据的变化:
Speed _speed = Speed.initial;
int _timeRecord = 0;
void onSnakeChange();
void tickRender(Direction direction){
int cur = DateTime.now().millisecondsSinceEpoch;
bool timeSkip = cur - _timeRecord < speed.time;
if(timeSkip) return;
move(direction);
onSnakeChange();
_timeRecord = cur;
}
2.游戏主类处理
将数据维护和事件触发的逻辑交由其他的混入类执行之后,SnakeGame 就可以坐享其成,通过混入的方式获得对应的能力,从而减少其代码压力。此时只要处理三个回调即可:
- 在
onDirectionChange
方向变化时触发tickRender
; - 在
update
回调中不断触发tickRender
; - 在
onSnakeChange
中更新世界的数据。
这样就实现了贪吃蛇最基本的运动逻辑:
class SnakeGame extends FlameGame
with KeyboardEvents, DirectionCtrlMixin, Configable, WorldRender
implements DirectionChange {
@override
void onDirectionChange(Direction direction) {
tickRender(direction);
}
Ground ground = Ground();
@override
FutureOr<void> onLoad() {
add(ground);
return super.onLoad();
}
@override
void update(double dt) {
super.update(dt);
tickRender(lastDirection);
}
@override
void onSnakeChange() {
ground.updateSnake();
}
}
3. 视图构件处理
游戏界面的重要构件是网格场地,它包括 Snake 和 Foods 两个子构件:
onGameResize
时为场地设置大小,并且处于屏幕中间。- updateSnake 和 updateFoods 方法会根据数据渲染器中的数据,更新蛇和食物的展示。
- render 回调中,绘制网格线。
class Ground extends PositionComponent with HasGameRef<SnakeGame> {
@override
void onGameResize(Vector2 size) {
this.size = game.gridSize;
x = (size.x - width) / 2;
y = (size.y - height) / 2;
super.onGameResize(size);
}
@override
FutureOr<void> onLoad() {
updateSnake();
updateFoods();
return super.onLoad();
}
void updateSnake() {
removeWhere((e) => e is Snake);
add(Snake(game.snakeList));
}
void updateFoods() {
removeWhere((e) => e is Foods);
add(Foods(game.foodList));
}
@override
void render(Canvas canvas) {
drawGrid(canvas, game.boxSize, game.rows, game.columns);
}
Paint girdPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = const Color(0xffffffff);
void drawGrid(Canvas canvas, double boxSize, int row, int column) {
Path path = Path();
double width = row * boxSize;
double height = column * boxSize;
for (int i = 0; i <= column; i++) {
path.moveTo(0, boxSize * i);
path.relativeLineTo(width, 0);
}
for (int i = 0; i <= row; i++) {
path.moveTo(boxSize * i, 0);
path.relativeLineTo(0, height);
}
canvas.drawPath(path, girdPaint);
}
}
目前贪吃蛇和食物都是基于点列表,渲染的色块,所以构建逻辑是一致的。但为了区分类别,以及后续的拓展,这里还是将其分为两个构件单独处理:
class Snake extends PositionComponent {
final Iterable<Point<int>> points;
Snake(this.points);
@override
FutureOr<void> onLoad() {
setFrame();
return super.onLoad();
}
void setFrame() {
removeWhere((e) => true);
int i = 0;
for (Point<int> point in points) {
CellType type = i == 0 ? CellType.snakeHeader : CellType.snakeBody;
add(Cell(point, type: type, side: 24));
i++;
}
}
}
class Foods extends PositionComponent {
final Iterable<Point<int>> points;
Foods(this.points);
@override
FutureOr<void> onLoad() {
setFrame();
return super.onLoad();
}
void setFrame() {
removeWhere((e) => true);
for (Point<int> point in points) {
add(Cell(point, type: CellType.food, side: 24));
}
}
}
4. 单元格的构建
单元格目前是简单的示意版,通过色块展示不同的信息。分为 头
、身
、食物
三种类型,由 CellType 进行表示。渲染逻辑就是根据坐标和类型画方块即可:
enum CellType { snakeHeader, snakeBody, food }
class Cell extends PositionComponent {
final Point<int> point;
final double side;
final CellType type;
Cell(
this.point, {
this.side = 20,
this.type = CellType.snakeBody,
}) : super(size: Vector2(side, side));
@override
FutureOr<void> onLoad() {
TextStyle style =
TextStyle(fontSize: 8, fontFamily: 'BlackOpsOne', package: 'life_game', color: textColor);
TextComponent text = TextComponent(
text: '${point.x},${point.y}',
textRenderer: TextPaint(style: style),
position: Vector2(point.x * side, point.y * side),
anchor: Anchor.center,
);
add(text);
text.position = text.position + size / 2;
return super.onLoad();
}
@override
void render(Canvas canvas) {
Paint paint = Paint()..color = bodyColor;
canvas.drawRect(Rect.fromLTWH(point.x * side, point.y * side, width, height), paint);
}
Color? get textColor {
if (type == CellType.snakeHeader || type == CellType.food) return Colors.white;
return Colors.grey;
}
Color get bodyColor {
if (type == CellType.snakeHeader) return Colors.blue;
if (type == CellType.food) return Colors.redAccent;
return Colors.white;
}
}
三、贪吃蛇碰撞检测
现在已经完成了小蛇的移动控制,接下来将处理碰撞检测。感知小蛇吃到食物以及死亡的校验。这样就完成了贪吃蛇的核心游戏逻辑。
1.吃到食物的逻辑处理
说到碰撞检测,可能很多朋友会想到用 CollisionCallbacks,其实目前的贪吃蛇不需要做这么复杂的碰撞校验。只要在移动过程中 蛇头 和 食物 的坐标重叠,就表示两者碰撞了。这是非常古朴的碰撞检测方式,尤其在宫格中非常实用:
如下所示,向左移动时,下一帧的蛇头将和食物在 (6,6) 坐标重叠,此时只要把 (6,6) 作为蛇头,其余不变即可:
代码处理如下,当 foodList
包含下一帧蛇头,就移除食物,再通过 generateNewFood
方法生成一个新食物。
void move(Direction direction) {
Point<int> oldHead = snakeList.first;
Point<int> newHead = switch (direction) {
Direction.up => Point(oldHead.x, oldHead.y - 1),
Direction.down => Point(oldHead.x, oldHead.y + 1),
Direction.left => Point(oldHead.x - 1, oldHead.y),
Direction.right => Point(oldHead.x + 1, oldHead.y),
};
snakeList.addFirst(newHead);
if (foodList.contains(newHead)) {
foodList.remove(newHead);
generateNewFood();
} else {
snakeList.removeLast();
}
}
新食物生成的逻辑最简单的处理方式是随机一个网格内的坐标,加入 foodList
列表。但是这样可能会导致新食物和小蛇重叠。这里采用了随机池,先收集所有的空点 foodPool
,然后在空点的范围内随机选一个:
void generateNewFood() {
Random random = Random();
Set<Point> exist = {...snakeList,...snakeList};
Set<Point<int>> foodPool = {};
for (int i = 0; i < column; i++) {
for (int j = 0; j < row; j++) {
foodPool.add((Point(i,j)));
}
}
foodPool = foodPool.difference(exist);
Point<int> newFood = foodPool.toList()[random.nextInt(foodPool.length)];
foodList.add(newFood);
onFoodChange(newFood);
}
2.小蛇的死亡校验
校验的逻辑可以在移动 move 方法时进行处理,同样也是使用古朴的方式。只需要校验贪吃蛇的新头是否在网格之外,或者身体部分是否包含新头(表示已缠绕)即可。可以给一个死亡回调 onDied
,通知外界死亡的时机:
bool _checkAlive(Point<int> newHead) {
if (newHead.x < 0 ||
newHead.y < 0 ||
newHead.x >= column ||
newHead.y >= row ||
snakeList.skip(1).contains(newHead)) {
return false;
}
return true;
}
4. 游戏的状态
为了控制游戏的状态,可以暂停、开启、死亡,这里定义一个 GameStatus
枚举,在 WorldRender
中维护这个状态。在 tickRender
方法中,当状态非 playing ,直接返回,不进行渲染:
enum GameStatus { ready, playing, paused, died }
mixin WorldRender on Configable {
///...
GameStatus status = GameStatus.ready;
void tickRender(Direction direction) {
if (status != GameStatus.playing) return;
另外,游戏 update 回调中,可以在非 playing 时暂停游戏,减少不必要的渲染。最后,只要在 onDied 回调中,将 status 置为 diead 即可:
---->[SnakeGame]----
@override
void update(double dt) {
super.update(dt);
if(status!=GameStatus.playing){
paused = true;
}
tickRender(lastDirection);
}
最后,可以取出示意的坐标数字,查看一下效果。到这里,贪吃蛇的核心逻辑就处理完毕了。下一篇会继续优化贪吃蛇的表现,敬请期待 ~