Flutter&Flame游戏实践#20 | 贪吃蛇 - 核心逻辑

575 阅读11分钟

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


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

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

第一季:30 篇短文章,快速了解 Flame 基础。[已完结]
第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。

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


在计算机兴起的几十年来,有一些非常经典的休闲小游戏。经久不衰,它们通常规则简单,非常适合作为入门游戏的练习项目。接下来的几篇中,通过介绍一些经典小游戏的实现。第一位是贪吃蛇。它的规则非常简单:

一条小小的蛇在空间中不断觅食而增长。在碰壁或者缠绕时,游戏失败。


一、 贪吃蛇数据与交互分析

贪吃蛇其实和之前做的 生命游戏 是非常类似的,都是宫格中的点集运算与展示。我们首先分析一下,贪吃蛇游戏中,数据和交互操作的关系。

07.gif


1. 数据结构分析

贪吃蛇本质是宫格中的点集列表,每个点表示横纵坐标位置,它决定宫格中需要渲染的格点位置。下面可以开启上帝视角,展示一下每个宫格的坐标值:其中

  • 蓝色+白色代表蛇,蓝色是蛇头,白色是蛇身。这里的蛇由 (10, 8)、(10, 9)、(10, 10) 三个坐标构成。
  • 红色代表食物。此时的坐标为 (6,6) 点。

image.png

考虑到希望可以在宫格中生成多个随机的食物,所以这里蛇和食物的核心数据都是格点列表。考虑到后期蛇的身体会频繁地增删节点,可以考虑使用 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 ,但这并不是最佳方案。

01.gif

仔细分析一下可以发现,我们只需要在蛇头的上方增加一格作为新头,然后将尾部的节点移除。这样就可以保证蛇的总长不变,所有格点纵坐标向上平移一位。
这就是使用 双端队列 的优势,它可以在 O(1) 的时间复杂度下处理首尾元素的操作,而让所有节点平移是 O(n) 的复杂度。如果蛇的身体非常长,这种算法就有很大的优势。

image.png


2. 向右移动

同理,向右移动,就是先找到 下一帧的头结点 坐标,将其入栈;再将尾节点出栈。如下所示:

02.gif

  • 第一帧的头部是 (10,8),第二帧向右移动,那么新头将是 (11,8)
  • 再移除尾部节点,就可以得到第二帧的小蛇数据,触发渲染即可。

image.png


3.根据方向更新下一帧数据

小蛇运动的方向有上下左右四个,可数尽的元素可以通过枚举表示。如下定义 Direction

enum Direction {
  up,
  down,
  left,
  right;
}

根据上下左右可以预测下一帧头部坐标,然后添头去尾即可。由下面的 move 方法 对数据进行处理,效果如下所示:

03.gif

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);
}

由于贪吃蛇规则中,头部碰到其他身体部分视为缠绕,而游戏结束。所以运行中的贪吃蛇只有三个可选择的方向,可以记录上一次的运行方向,然后忽略反向的按键即可。比如当前运行方向向下,那么向上的操作需要本禁止:

image.png

于是,可以写出下面的 DirectionCtrlMixin 全权负责方向变化的触发逻辑。此时在 SnakeGame 中,就可以通过混入 DirectionCtrlMixin 实现 DirectionChange 接口来感知 onDirectionChange 的变化,处理逻辑即可:

image.png

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 中更新世界的数据。

这样就实现了贪吃蛇最基本的运动逻辑:

04.gif

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. 视图构件处理

游戏界面的重要构件是网格场地,它包括 SnakeFoods 两个子构件:

  • onGameResize 时为场地设置大小,并且处于屏幕中间。
  • updateSnake 和 updateFoods 方法会根据数据渲染器中的数据,更新蛇和食物的展示。
  • render 回调中,绘制网格线。

image.png

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 进行表示。渲染逻辑就是根据坐标和类型画方块即可:

image.png

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;
  }
}

三、贪吃蛇碰撞检测

现在已经完成了小蛇的移动控制,接下来将处理碰撞检测。感知小蛇吃到食物以及死亡的校验。这样就完成了贪吃蛇的核心游戏逻辑。

06.gif


1.吃到食物的逻辑处理

说到碰撞检测,可能很多朋友会想到用 CollisionCallbacks,其实目前的贪吃蛇不需要做这么复杂的碰撞校验。只要在移动过程中 蛇头食物 的坐标重叠,就表示两者碰撞了。这是非常古朴的碰撞检测方式,尤其在宫格中非常实用:
如下所示,向左移动时,下一帧的蛇头将和食物在 (6,6) 坐标重叠,此时只要把 (6,6) 作为蛇头,其余不变即可:

image.png

代码处理如下,当 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 ,然后在空点的范围内随机选一个:

05.gif

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 ,通知外界死亡的时机:

image.png

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 即可:

image.png

---->[SnakeGame]----
@override
void update(double dt) {
  super.update(dt);
  if(status!=GameStatus.playing){
    paused = true;
  }
  tickRender(lastDirection);
}

最后,可以取出示意的坐标数字,查看一下效果。到这里,贪吃蛇的核心逻辑就处理完毕了。下一篇会继续优化贪吃蛇的表现,敬请期待 ~

07.gif