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


theme: cyanosis

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


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

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

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

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


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

本章目标:

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

本篇源码详见: 【tolygame/modules/lifegame/lib/02】


一、自动播放与暂停

效果如下所示:

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

1.需求数据分析

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

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

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

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

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

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


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

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

```dart class EvolveSpeed extends Equatable{ final double level;

static const kWorldTimeUnit = 1000;

const EvolveSpeed._(this.level);

static List 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 get props => [level]; } ```


2. 业务逻辑与通知更新

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

```dart 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 方法进行一次演化,其中通过 上一次演化的时间戳差速率时间间隔 对比;确定是否需要演化:

```dart 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? 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 中已经进行过更新时间间隔的限制,所以并不会每个游戏帧都触发演化:


3. 视图层处理

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

考虑到 FrameEvolve 是个比较大的可监听对象,我们可以通过 ValueNotifier 来将其拆成局部组件感兴趣的小状态。比如左上角的游戏控制按钮,只对 EvolveStatus 这个枚举状态感兴趣,速率改变、演化次数改变都与它的视图表现无关。

所以这里拆分出 PlayCtrlButton 组件,传入 ValueNotifier<EvolveStatus> 只感知 EvolveStatus 的状态变化。并通过 onAction 回调点击事件。这样,可以使用 ValueListenableBuilder 监听 status 的变化,在构建逻辑中就可以基于 EvolveStatus 数据,决定视图的表现。

```dart class PlayCtrlButton extends StatelessWidget { final ValueNotifier status; final ValueChanged 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:

```dart class _LifeGameViewState extends State { final LifeGame game = LifeGame(); late ValueNotifier _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 就会持续触发,从而带动世界的演化;

```dart 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 组件


二、可编辑宫格生命

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


1. 激活模式控制

可编辑宫格生命,就是说在宫格的点击拖拽事件中,根据落点坐标来 添加移除 格点对应的细胞。其中添加和移除,通过侧栏按钮的选中状态进行控制。如果每个按钮的激活状态,都通过一个数据来控制,会让逻辑变得非常复杂。

另外,考虑到有些按钮是互斥的,比如 画笔激活时,需要取消激活 移动橡皮擦。这里在 FrameEvolve 中维护一个 Map<ToolAction, bool> 的映射对象,来记录侧栏按钮的激活关系。通过 get 方法访问按钮是否激活:

```dart --->[FrameEvolve]---- final Map _selectedActionMap = {};

List 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 是互斥的,一者激活时,其他两个取消激活:

```dart 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 ? 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 的表现:

```dart class ActionToolbar extends StatelessWidget { final ValueChanged onAction; final ValueNotifier status; final ValueNotifier > 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 方法即可。


3.点击和拖拽事件处理

点击和拖拽事件发生在 Flame 中的网格中,它的处理方式和之前介绍的 《扫雷》 是类似的。主要是在交互时,更新网格的地图数据,也就是 Frame 中 spaces 映射数据,再重新渲染。

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

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

```dart mixin GridActionLogic on DragCallbacks, TapCallbacks, HasGameRef {

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 的世界,那该怎么办呢?


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

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

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

```dart 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 值完成缩放:

dart 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 来启动一帧:

dart 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 的网格中体验生命游戏。下一章将带来大量网格下,真正的生命游戏体验,敬请期待~

相关推荐
liulian09162 小时前
Flutter 网络状态与内容分享库:connectivity_plus 与 share_plus 的 OpenHarmony 适配指南
网络·flutter
KKei16383 小时前
Flutter for OpenHarmony 学习视频播放器技术文章
学习·flutter·华为·音视频·harmonyos
KKei16384 小时前
Flutter for OpenHarmony 健身计划与运动打卡APP
flutter·华为·harmonyos
KKei16384 小时前
Flutter for OpenHarmony 在线考试与自测系统APP技术文章
flutter·华为·harmonyos
阿斯加德D4 小时前
我的世界生活大冒险整合包下载高版本2026最新分享
测试工具·游戏·游戏程序·生活·材质
liulian09166 小时前
Flutter 依赖注入与设备信息库:get_it 与 device_info_plus 的 OpenHarmony 适配指南
flutter
胖胖熊℡6 小时前
C语言打字母游戏,多版本(117-118)
游戏
两水先木示6 小时前
【Unity】使用AI辅助开发Unity游戏流程
游戏
KKei16387 小时前
Flutter for OpenHarmony学习目标追踪应用技术文章
学习·flutter·华为·harmonyos