likes
comments
collection
share

Flutter&Flame游戏实践#16 | 生命游戏 - 编辑与交互

作者站长头像
站长
· 阅读数 22

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

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

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


上一章,我们完成了生命游戏最最重要逻辑规则,实现了如下简易版的生命游戏演绎界面。本章将继续优化项目

Flutter&Flame游戏实践#16 | 生命游戏 - 编辑与交互

本章目标:

  • 实现演绎的自动播放与暂停。
  • 实现演绎速率可配置。
  • 实现宫格生命的编辑功能。
  • 实现世界视口的移动与缩放。

本篇源码详见: 【toly_game/modules/life_game/lib/02】


一、自动播放与暂停

效果如下所示:

  • 左侧菜单栏最上方的按钮,控制自动播放与暂停。
  • 右下角支持选择切换世界演化的速度倍率。
  • 右下角展示当前世界迭代的次数。

Flutter&Flame游戏实践#16 | 生命游戏 - 编辑与交互


1.需求数据分析

在上面的三个小需求中,有三个数据影响界面中的视图表现。分别是:

  • [1]:当前播放状态,影响左上角播放按钮的展示。
  • [2]:当前播放的速率状态,影响右下角速率选择器和游戏主界面的迭代速度。
  • [3]:世界迭代的次数,影响右下第几代的展示。

其中红色区域是需要随状态数据变化的视图内容;蓝色监听是事件触发的行为,引发数据的变化:

Flutter&Flame游戏实践#16 | 生命游戏 - 编辑与交互

对于播放状态数据,这里将世界演化通过 EvolveStatus 表示,只有演化中停止演化 两种状态;演化的次数通过一个 int 值表示即可:

/// 演化状态
enum EvolveStatus {
  evolving,
  stopped,
}

/// 演化次数
int _generationCount = 1;

演化速率本应只是一个 double 数字。但这里想要控制支持的速率范围,并且统一计算速率对应的时间,所以将演化速率封装为一个 EvolveSpeed 类。其中:

  • 提供了 kSupports 列表作为支持的速率选择范围;
  • 私有化构造方法,不希望外界创建对象,来添加其他速率。
  • 世界演化的单位是 1000 ms/次,提供 time 方法统一换算速率对应的时间。
class EvolveSpeed extends Equatable{
  final double level;

  static const kWorldTimeUnit = 1000;

  const EvolveSpeed._(this.level);

  static List<EvolveSpeed> kSupports = [ 0.5, 1.0,2.0,3.0, 5.0, 10.0, 20.0]
      .map((e) => EvolveSpeed._(e))
      .toList();

  static EvolveSpeed initial = const EvolveSpeed._(1);

  double get time => kWorldTimeUnit/level;
  
  @override
  List<Object?> get props => [level];
}

2. 业务逻辑与通知更新

我们可以使用任何状态管理工具,来维护状态数据和触发界面更新。这里目前功能还比较简单,先通过 ChangeNotifier 来维护数据和通知更新。如下所示,定义 FrameEvolve 类来维护上述的三个状态数据,并在状态数据发生变化时,通知监听者:

class FrameEvolve with ChangeNotifier {

  /// 速度状态
  EvolveSpeed _speed = EvolveSpeed.initial;

  EvolveSpeed get speed => _speed;

  set speed(EvolveSpeed value) {
    _speed = value;
    notifyListeners();
  }
  
  /// 演化次数状态
  int _generationCount = 1;
  
  int get generationCount => _generationCount;
  
  set generationCount(int value) {
    _generationCount = value;
    notifyListeners();
  }
  
  /// 演化状态
  EvolveStatus _status = EvolveStatus.stopped;

  EvolveStatus get status => _status;
  
  set status(EvolveStatus value) {
    _status = value;
    notifyListeners();
  }

除了状态数据,FrameEvolve 还承担演化的职责,所以其中维护了上章中的 Frame 对象,也就是世界中细胞存活状态的数据。调用 evolve 方法进行一次演化,其中通过 上一次演化的时间戳差速率时间间隔 对比;确定是否需要演化:

late Frame frame;
XY size;
int _timeRecord = 0;

FrameEvolve(this.size) {
  reset();
}

void reset() {
  frame = Frame(size);
  generationCount = 1;
  status = EvolveStatus.stopped;
  _timeRecord = 0;
}

void evolve([ValueChanged<Frame>? onEvolved]) {
  int cur = DateTime.now().millisecondsSinceEpoch;
  bool timeSkip = cur - _timeRecord < _speed.time;
  bool evolving = status == EvolveStatus.evolving;
  if (timeSkip && evolving) return;
  frame.evolve();
  onEvolved?.call(frame);
  _generationCount++;
  notifyListeners();
  _timeRecord = DateTime.now().millisecondsSinceEpoch;
}

我们知道 Flame 游戏引擎在非暂停状态时,GameLoop 会让构件的 update 会持续触发。所以在主游戏类持有 FrameEvolve,并在 update 回调中不断触发 evolve 即可,由于 FrameEvolve#evolve 中已经进行过更新时间间隔的限制,所以并不会每个游戏帧都触发演化:

Flutter&Flame游戏实践#16 | 生命游戏 - 编辑与交互


3. 视图层处理

到这里,游戏的状态数据变化已经就绪,接下来对接界面表现和事件的触发。再回到这张图上,左侧和底部的组件是 Flutter 层的视图,我们可以监听 LifeGame 中的 FrameEvolve 状态变化,来通知界面更新:

考虑到 FrameEvolve 是个比较大的可监听对象,我们可以通过 ValueNotifier 来将其拆成局部组件感兴趣的小状态。比如左上角的游戏控制按钮,只对 EvolveStatus 这个枚举状态感兴趣,速率改变、演化次数改变都与它的视图表现无关。 所以这里拆分出 PlayCtrlButton 组件,传入 ValueNotifier<EvolveStatus> 只感知 EvolveStatus 的状态变化。并通过 onAction 回调点击事件。这样,可以使用 ValueListenableBuilder 监听 status 的变化,在构建逻辑中就可以基于 EvolveStatus 数据,决定视图的表现。

class PlayCtrlButton extends StatelessWidget {
  final ValueNotifier<EvolveStatus> status;
  final ValueChanged<ToolAction> onAction;

  const PlayCtrlButton({super.key, required this.status, required this.onAction});

  @override
  Widget build(BuildContext context) {
    ActionStyle style = const ActionStyle(
      backgroundColor: Colors.black,
      padding: EdgeInsets.all(2),
      borderRadius: BorderRadius.all(Radius.circular(4)),
    );


    return ValueListenableBuilder(
      valueListenable: status,
      builder: (BuildContext context, EvolveStatus value, Widget? child) {
        Color? color;
        IconData icon;
        switch(value){
          case EvolveStatus.evolving:
            color = Colors.red;
            icon = TolyIcon.icon_pause;
            break;
          case EvolveStatus.stopped:
            icon = TolyIcon.icon_play;
            color = Colors.green;
        }

        return TolyAction(
          style: style,
          child: Icon(icon, size: 18,color: color,),
          onTap: () => onAction(ToolAction.play),
        );
      },
    );
  }
}

最后一个问题就是 PlayCtrlButton 构造入参中的 ValueNotifier<EvolveStatus> 从哪里来? FrameEvolve 相当于一个大广播,播报所有状态变化的事件;而 _statusNtf 相当于一个消息的 二道贩子。它的任务是:听着广播里的某一个数据的变化,当新值和旧值不同时,就把它通知给特定的人,也就是 PlayCtrlButton:

class _LifeGameViewState extends State<LifeGameView> {
  final LifeGame game = LifeGame();
  late ValueNotifier<EvolveStatus> _statusNtf;

  @override
  void initState() {
    _statusNtf = ValueNotifier(game.frameEvolve.status);
    game.frameEvolve.addListener(_onEvolveChange);
    super.initState();
  }

  void _onEvolveChange() {
    EvolveStatus newStatus = game.frameEvolve.status;
    if (_statusNtf.value != newStatus) {
      _statusNtf.value = newStatus;
    }
  }
  
  @override
  void dispose() {
    super.dispose();
    _statusNtf.dispose();
    game.frameEvolve.removeListener(_onEvolveChange);
  }

最后,当监听到 ToolAction.play 的事件时,游戏主类触发 play 方法,停止或恢复游戏。比如 start 时,paused 为 false,这样 update 就会持续触发,从而带动世界的演化;

Flutter&Flame游戏实践#16 | 生命游戏 - 编辑与交互

void play() {
  if (frameEvolve.status == EvolveStatus.evolving) {
    stop();
  } else {
    start();
  }
}

void start() {
  if (frameEvolve.status == EvolveStatus.evolving) {}
  paused = false;
  frameEvolve.status = EvolveStatus.evolving;
}

void stop() {
  paused = true;
  frameEvolve.status = EvolveStatus.stopped;
}

另外两个状态的视图层也是类似,这里就不赘述了。可以详见源码。其中速率的选择器,使用了 TolyUI 中的 TolyDropMenu 组件


二、可编辑宫格生命

下面来处理宫格中生命的编辑功能,如下所示,在侧栏菜单中有画笔橡皮擦 两个按钮.画笔模式下,按下或拖拽时,可以让空间中诞生细胞;橡皮擦模式相反,将存在的细胞杀死。绘制完满意的排布方式之后,就可以播放,或者逐代演化:

Flutter&Flame游戏实践#16 | 生命游戏 - 编辑与交互


1. 激活模式控制

可编辑宫格生命,就是说在宫格的点击拖拽事件中,根据落点坐标来 添加移除 格点对应的细胞。其中添加和移除,通过侧栏按钮的选中状态进行控制。如果每个按钮的激活状态,都通过一个数据来控制,会让逻辑变得非常复杂。 另外,考虑到有些按钮是互斥的,比如 画笔激活时,需要取消激活 移动橡皮擦。这里在 FrameEvolve 中维护一个 Map<ToolAction, bool> 的映射对象,来记录侧栏按钮的激活关系。通过 get 方法访问按钮是否激活:

--->[FrameEvolve]----
final Map<ToolAction, bool> _selectedActionMap = {};

List<ToolAction> get actions => _selectedActionMap.keys.toList();

 bool get seeWorld => _selectedActionMap[ToolAction.see] ?? false;
 bool get paintMode => _selectedActionMap[ToolAction.paint] ?? false;
 bool get deleteMode => _selectedActionMap[ToolAction.eraser] ?? false;

然后,通过 handleAction 方法处理事件。_toggleAndRemove 处理激活时,取消指定激活项。

  • ToolAction.see 用于开启和关闭上帝视角。
  • ToolAction.paint 启用绘制模式。
  • ToolAction.eraser 启用删除模式。
  • ToolAction.move 启用移动模式。

其中 paint、eraser、move 是互斥的,一者激活时,其他两个取消激活:

void handleAction(ToolAction action) {
  switch (action) {
    case ToolAction.see:
      _toggleAndRemove(action);
      break;
    case ToolAction.paint:
      _toggleAndRemove(action, [ToolAction.eraser, ToolAction.move]);
      break;
    case ToolAction.eraser:
      _toggleAndRemove(action, [ToolAction.paint, ToolAction.move]);
      break;
    case ToolAction.move:
      _toggleAndRemove(action, [ToolAction.eraser, ToolAction.paint]);
      break;
    default:
  }
  notifyListeners();
}

/// [action] 激活时,需要取消激活 [removeList]
void (ToolAction action, [List<ToolAction>? removeList]) {
  bool select = _selectedActionMap[action] ?? false;
  if (select) {
    _selectedActionMap.remove(action);
  } else {
    _selectedActionMap[action] = true;
    removeList?.forEach(_selectedActionMap.remove);
  }
}

2. 视图层和事件处理

视图层同理,监听 FrameEvolve 中状态数据的变化,通过 ValueNotifier<List<ToolAction>> 贩卖激活项列表信息,在 ActionToolbar 的构造函数传入。这样在构建按钮时,可以监听激活信息数据,设置 TolyAction#selected 的表现:

Flutter&Flame游戏实践#16 | 生命游戏 - 编辑与交互

class ActionToolbar extends StatelessWidget {
  final ValueChanged<ToolAction> onAction;
  final ValueNotifier<EvolveStatus> status;
  final ValueNotifier<List<ToolAction>> actions;
 
 ...
 
 /// 构建条目时 
 if(e==ToolAction.see || e==ToolAction.paint ||
   e==ToolAction.eraser || e==ToolAction.move){
  return ValueListenableBuilder(
    valueListenable: actions,
    builder: (context,value,__) {
      return TolyAction(
        selected: value.contains(e),
        style: style,
        child: Icon(e.icon, size: 18),
        onTap: () => onAction(e),
      );
    },
  );
}

按钮的点击事件触发 _onAction ,在 paintmoveeraser 时,通过 game 触发 FrameEvolve#handleAction 方法即可。

Flutter&Flame游戏实践#16 | 生命游戏 - 编辑与交互


3.点击和拖拽事件处理

Flutter&Flame游戏实践#16 | 生命游戏 - 编辑与交互

为了让业务逻辑尽可能和视图分离,这里使用 GridActionLogic 作为 mixin 处理交互逻辑。这样 SpaceManager 只要混入 GridActionLogic 即可拥有点击和拖拽的交互逻辑:

Flutter&Flame游戏实践#16 | 生命游戏 - 编辑与交互

点击和拖拽都会触发 pressed 方法,通过事件中的 localPosition 可以得到相对于网格左上角的落点坐标。然后通过 trans 方法,将落点坐标转换为网格坐标即可。最后根据当前的模式和细胞存活状态,诞生或杀死对应宫格的细胞:

mixin GridActionLogic on DragCallbacks, TapCallbacks, HasGameRef<LifeGame>{

  double get cellSize;

  @override
  void onTapDown(TapDownEvent event) {
    pressed(event.localPosition);
    super.onTapDown(event);
  }

  @override
  void onDragUpdate(DragUpdateEvent event) {
    pressed(event.localStartPosition);
    super.onDragUpdate(event);
  }

  void pressed(Vector2 vector2) {
    XY position = trans(vector2);
    bool alive = game.frameEvolve.frame.spaces[position]==true;
    if(game.frameEvolve.paintMode){
      if(!alive){
        game.birth(position);
      }
    }
    if(game.frameEvolve.deleteMode){
      if(alive){
        game.died(position);
      }
    }
  }

  XY trans(Vector2 vector2) {
    int x = vector2.x ~/ cellSize;
    int y = vector2.y ~/ cellSize;
    return (x, y);
  }
}

三、游戏视口的缩放与偏移

如下所示,在移动模式下,可以通过鼠标滚轮进行缩放拖拽平移。Flame 中并没有鼠标的滚轮事件,而交互界面时 Flame 的世界,那该怎么办呢?

Flutter&Flame游戏实践#16 | 生命游戏 - 编辑与交互


1. Flame 世界本质上也是一个 Widget

GameWidget 展示 Flame 的游戏世界,在它的上层可以套一个 Flutter Widget,这样鼠标事件就可以在 Flutter 这边处理。这里封装了一个 TransformWrapper 的组件处理变换:

Flutter&Flame游戏实践#16 | 生命游戏 - 编辑与交互

所以 Listener 组件,可以在 onPointerSignal 中监听鼠标的滚轮事件; onPointerMove 中监听触点的拖拽事件。在其中处理具体的变换逻辑即可。

class TransformWrapper extends StatelessWidget {
  final Widget child;
  final LifeGame game;

  const TransformWrapper({super.key, required this.child, required this.game});

  @override
  Widget build(BuildContext context) {
    return ClipRect(
      child: Listener(
        onPointerSignal: _onPointerSignal,
        onPointerMove: _onPointerMove,
        child: child,
      ),
    );
  }

  void _onPointerSignal(PointerSignalEvent event) {
    if (!game.frameEvolve.moveMode) return;
    // TODO 缩放
  }

  void _onPointerMove(PointerMoveEvent event) {
    if (!game.frameEvolve.moveMode) return;
     // TODO 移动
  }
}

2. Flame 相机变换的应用

_onPointerSignal 方法用于处理鼠标滚轮的事件,其中会回调 PointerSignalEvent 事件,通过竖直方向的偏移量可以校验鼠标滚轮滚动的方向。根据方向改编相机的 zoom 值完成缩放:

void _onPointerSignal(PointerSignalEvent event) {
  if (!game.frameEvolve.moveMode) return;
  if (event is PointerScrollEvent) {
    bool larger = event.scrollDelta.dy < 0;
    double curZoom = game.camera.viewfinder.zoom;
    double newZoom = 0;
    if (larger) {
      newZoom = curZoom + 0.1;
    } else {
      newZoom = curZoom - 0.1;
    }
    if (newZoom < 0.01 || newZoom > 20) return;
    Viewfinder viewfinder = game.camera.viewfinder;
    viewfinder.zoom = newZoom;
    game.paused = false;
  }
}

_onPointerSignal 方法用于处理鼠标拖拽事件,根据偏移量和当前缩放值,使用 moveBy 对相机进行偏移。注意一点,目前的游戏世界是出于暂停状态的,想要相机变化生效,需要 game.paused = false 来启动一帧:

void _onPointerMove(PointerMoveEvent event) {
  if (!game.frameEvolve.moveMode) return;
  double curZoom = game.camera.viewfinder.zoom;
  Offset delta = event.delta / curZoom;
  game.camera.moveBy(Vector2(-delta.dx, -delta.dy));
  game.paused = false;
}

到这里,我们的生命游戏已经万事俱备了。目前只是在 9*9 的网格中体验生命游戏。下一章将带来大量网格下,真正的生命游戏体验,敬请期待~

Flutter&Flame游戏实践#16 | 生命游戏 - 编辑与交互

转载自:https://juejin.cn/post/7393688446038507554
评论
请登录