Flutter&Flame游戏实践#21 | 贪吃蛇 - 双人对战

916 阅读4分钟

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


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

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

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

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


上一篇实现了贪吃蛇的基本逻辑,包括蛇的移动控制、食物的获取以及游戏结束的校验。本文将进一步完善游戏功能,包括:

  • 支持分数、速度、时间等信息的展示。
  • 游戏暂停和结束界面示意,以及重新开始。
  • 支持界面中多个食物,并且食物标有颜色和分数。
  • 支持双人对战。

如下所示,每次吃到食物时,会将身体的这一格染成对应颜色,并且分数值增加食物的数值,每当分数 +100,速度将会增加 1 倍;右上角展示游戏进行的时长。下方中间的文字可以示意游戏暂行运行中死亡的状态。

08.gif


双人对战效果如下,目前处理的比较简单。支持两个玩家在同一台设备上,控制各自的一条蛇;其中一条通过 wasd 字母键,另一条通过 上下左右 键控制,效果如下所示:

09.gif


一、头部和底部信息展示

游戏界面在布局上非常简单,顶部展示游戏状态信息,一般称之为 HUD (Heads-Up Display);目前包含三个部分:

  • 游戏得分 : ScoreHud
  • 游戏速度 : SpeedHud
  • 游戏时间 : TimeHud

image.png

底部包含玩家信息,游戏状态,以及一个设置文字(暂时无用)。


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 方法设置构件的分数数值:

image.png

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 游戏场景中:

image.png

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 就可以向右这些数据:

image.png


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 承载数据,并在数据变化时产出。


三、食物和蛇颜色的处理

当蛇和食物需要支持颜色,就表示它们每个节点的数据已经不再是简单坐标值,而需要添加额外的属性。

image.png


1. 节点数据的增强

现在蛇的数据需要增加颜色值,定义如下的 SnakeNode 节点。此时状态数据中就需要使用 Queue<SnakeNode> 队列维护蛇的数据:

image.png

class SnakeNode {
  final Point<int> position;
  Color? color;

  SnakeNode({
    required this.position,
    this.color,
  });
}

食物增加了颜色值和分数值,定义如下的 SnakeNode 节点维护。此时状态数据中就需要使用 List<SnakeNode> 列表维护食物的数据:

image.png

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 节点队列;每个玩家也都需要记录其操作的方向键。

09.gif


1. 玩家类 Player

如下所示,定义 Player 玩家类,其中包括名称和颜色用于区分。除此之外,持有 Queue<SnakeNode> Direction 成员。然后将之前 GameStateMixin 中的 Queue<SnakeNode> 升级为 Player 列表:

  • 通过 checkDirection 方法校验是否允许该方向的操作,允许时更新 lastDirection;
  • reset 方法,在指定点位,初始化队列中的内容,用于游戏重新开始的处理。

image.png

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 枚举用于表示方向控制的类型。arrowwasd 分别代表方向键和字母键;然后将 onDirectionChange 接口增加 DirectionType 类型的参数:

enum DirectionType{
  arrow,
  wasd
}

abstract class GameOperation {
  void onDirectionChange(Direction direction,DirectionType type);

然后在 DirectionCtrlMixin 中,键盘事件中再校验 wasd 字母键,触发 onDirectionChange 回调即可。在游戏主类中,监听到方向键的变化时,根据类型处理不同玩家的方向校验:

image.png

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 构件, 它们都由一个变成多个,需要根据玩家数遍历添加:

image.png

如下所示,在 Ground#updateSnake 中,遍历 game.players 添加 Snake 构件。其中 Snake 需要增加一个颜色参数表示蛇头的颜色,来区分玩家:

image.png

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

到这里,双人对战版的贪吃蛇就基本实现了,不过目前只要一个玩家输了,就直接终止游戏,处理的比较粗暴。你也可以自己优化一下逻辑,比如一个玩家死亡时,散落为食物什么的,增强游戏可玩性。另外,如果方格看起来感觉不太美观,你可以在方块中放置图片来优化视觉表现,那本章就到这里,谢谢观看 ~