likes
comments
collection
share

Flutter Flame 实现接龙纸牌游戏教程(二):骨架搭建

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

在本节中,我们将概述游戏的主要元素,包括主游戏类和总体布局。

KlondikeGame

在Flame框架中,FlameGame类是大多数游戏的基石。此类负责运行游戏循环、分派事件、拥有构成游戏的所有组件(组件树),通常还作为游戏状态的中央存储库。

所以,在 lib\ 文件夹中创建一个名为klondike_game.dart的新文件,并在其中声明KlondikeGame类:

import 'package:flame/game.dart';
import 'package:flame/flame.dart';

class KlondikeGame extends FlameGame {
  @override
  Future<void> onLoad() async {
    await Flame.images.load('klondike-sprites.png');
  }
}

现在我们只声明了 onLoad 方法,这是一个特殊的处理器,当游戏实例首次附加到 Flutter 小部件树时会调用它。你可以将其视为一个延迟的异步构造函数。当前,onLoad 方法的唯一功能是将精灵图像加载到游戏中;但我们很快会添加更多功能。任何你希望在游戏中使用的图像或其他资源都需要首先加载,这个过程是一个相对较慢的 I/O 操作,因此需要使用 await 关键字。

我将图像加载到全局的 Flame.images 缓存中。另一种方法是将其加载到 Game.images 缓存中,但那样就比较难从其他类中访问该图像了。

另外请注意,我在初始化游戏中的其他内容之前等待图像加载完成。这是为了方便:这样一来,当所有其他组件初始化时,它们可以假设精灵表已经加载完毕。我们甚至可以添加一个辅助函数来从公共精灵表中提取精灵:

Sprite klondikeSprite(double x, double y, double width, double height) {
  return Sprite(
    Flame.images.fromCache('klondike-sprites.png'),
    srcPosition: Vector2(x, y),
    srcSize: Vector2(width, height),
  );
}

这个辅助函数在本章中不会用到,但是在下一章会被广泛使用。

为了将这个类合理地集成到项目中,使其不再孤立无援,打开 main.dart 文件,找到写有 final game = FlameGame(); 的那一行,将 FlameGame 替换为 KlondikeGame。你还需要导入这个类。完成后的文件应该如下所示:

import 'package:flame/game.dart';
import 'package:flutter/widgets.dart';
import 'klondike_game.dart';

void main() {
  final game = KlondikeGame();
  runApp(GameWidget(game: game));
}

其他类

目前我们已经有了主要的 KlondikeGame 类,现在我们需要创建一些将要添加到游戏中的对象。在 Flame 中,这些对象被称为组件(components),当它们被添加到游戏中时,会形成一个“游戏组件树”。所有在游戏中存在的实体都必须是组件。

正如我们在上一章中提到的,我们的游戏主要由 Card 组件组成。但是,由于绘制卡片需要一些努力,我们将把该类的实现推迟到下一章。

现在,让我们按照草图创建容器类。这些类是:StockWastePileFoundation。在 lib/ 文件夹中创建一个子目录 components,然后创建文件 lib/components/stock.dart。 在该文件中编写以下内容:

import 'package:flame/components.dart';

class Stock extends PositionComponent {
  @override
  bool get debugMode => true;
}

在这里,我们将 Stock 类声明为一个 PositionComponent(一个具有位置和大小的组件)。我们还为这个类启用了调试模式,这样即使我们还没有任何渲染逻辑,也能在屏幕上看到它。

同样地,创建另外三个类 FoundationPile 和 Waste,每个类放在其对应的文件中。目前,这四个类的逻辑完全相同,我们将在后续章节中为这些类添加更多功能。

此时,你的游戏目录结构应该如下所示:

klondike/
 ├─assets/
 │  └─images/
 │     └─klondike-sprites.png
 ├─lib/
 │  ├─components/
 │  │  ├─foundation.dart
 │  │  ├─pile.dart
 │  │  ├─stock.dart
 │  │  └─waste.dart
 │  ├─klondike_game.dart
 │  └─main.dart
 ├─analysis_options.yaml
 └─pubspec.yaml

游戏结构

一旦我们有了一些基本的组件,就需要将它们添加到游戏中。现在是时候决定游戏的高层结构了。

这里有多种不同的方法,它们在复杂性、可扩展性和总体理念上有所不同。本教程中我们将采用的方法是使用 World 组件和 Camera 组件。

这种方法背后的理念是:设想你的游戏世界独立于设备而存在,它已经存在于我们的脑海中和草图上,即使我们还没有编写任何代码。这个世界将有一定的大小,世界中的每个元素将有固定的坐标。由我们来决定这个世界的大小,以及尺寸的度量单位。重要的一点是,这个世界独立于设备存在,它的尺寸也不受屏幕分辨率的影响。

所有属于世界的元素都将被添加到 World 组件中,然后再将 World 组件添加到游戏中。

整体结构的第二部分是一个摄像机CameraComponent)。摄像机的目的是能够查看世界,并确保它在用户设备的屏幕上以正确的尺寸渲染。

因此,组件树的整体结构大致如下所示:

KlondikeGame
 ├─ World
 │   ├─ Stock
 │   ├─ Waste
 │   ├─ Foundation (×4)
 │   └─ Pile (×7)
 └─ CameraComponent

针对这个游戏,我在绘制图像资源时考虑到了单张牌的尺寸为 1000×1400 像素。因此,这将作为确定整体布局的参考尺寸。另一个影响布局的重要测量值是牌与牌之间的距离。这个距离应在 150 到 200 个单位之间(相对于牌的宽度),因此我们将其声明为一个可调整的变量 cardGap。为简化起见,牌之间的垂直和水平距离将相同,并且牌与屏幕边缘之间的最小间距也将等于 cardGap

好了,让我们将这些内容整合起来,来实现我们的 KlondikeGame 类。

首先,我们声明几个全局常量来描述一张牌的尺寸和牌与牌之间的距离。我们将它们声明为常量,因为我们不打算在游戏期间改变这些值:

  static const double cardWidth = 1000.0;
  static const double cardHeight = 1400.0;
  static const double cardGap = 175.0;
  static const double cardRadius = 100.0;
  static final Vector2 cardSize = Vector2(cardWidth, cardHeight);

接下来,我们将创建一个 Stock 组件、一个 Waste 组件、四个 Foundation 组件和七个 Pile 组件,并在世界中设置它们的尺寸和位置。这些位置将通过简单的算术计算得出。这些操作都应该在 onLoad 方法中完成,在加载了精灵图之后:

    final stock = Stock()
      ..size = cardSize
      ..position = Vector2(cardGap, cardGap);
    final waste = Waste()
      ..size = cardSize
      ..position = Vector2(cardWidth + 2 * cardGap, cardGap);
    final foundations = List.generate(
      4,
      (i) => Foundation()
        ..size = cardSize
        ..position =
            Vector2((i + 3) * (cardWidth + cardGap) + cardGap, cardGap),
    );
    final piles = List.generate(
      7,
      (i) => Pile()
        ..size = cardSize
        ..position = Vector2(
          cardGap + i * (cardWidth + cardGap),
          cardHeight + 2 * cardGap,
        ),
    );

从 Flame 1.9.0 版本开始,FlameGame 默认会设置 world 和 camera 对象。KlondikeGame 是 FlameGame 的扩展,所以我们可以直接将所有组件添加到这个默认的 world 中。这样可以简化我们的代码。

    world.add(stock);
    world.add(waste);
    world.addAll(foundations);
    world.addAll(piles);

注意事项

你可能会想知道在什么时候需要等待 add() 方法的结果,什么时候不需要。简短的答案是:通常你不需要等待,但是如果你想等待也没有问题。

如果你查看 add() 方法的文档,你会看到返回的 Future 仅仅等待组件加载完成,而不是等到它实际挂载到游戏中。因此,只有在你的逻辑要求组件在完全加载后才能继续操作时,你才需要等待 add() 方法的 Future。这种情况并不常见。

如果你不等待 add() 方法的 Future,组件也会被添加到游戏中,并且所需的时间相同。

最后一步,我们需要使用 FlameGame 的camera对象来查看world。相机内部由两个部分组成:视口(viewport)和取景器(viewfinder)。默认的视口是 MaxViewport,它占用整个可用屏幕的大小,这正是我们需要的,所以无需做任何更改。而取景器需要根据底层世界的尺寸进行设置。

我们希望整个纸牌布局在屏幕上可见,无需滚动。为此,我们需要指定整个世界的尺寸(即 7*cardWidth + 8*cardGap 的宽度和 4*cardHeight + 3*cardGap 的高度)能够适应屏幕。通过 .visibleGameSize 设置可以确保无论设备的大小如何,都会调整缩放级别,使指定的游戏世界部分可见。

游戏大小计算:

  • 宽度:桌上有7张牌和6个间隙,加上两侧的额外间隙,共计 7 * cardWidth + 8 * cardGap

  • 高度:垂直方向上有两排牌,但底排需要一些额外的空间来显示高堆,大致估算三倍牌的高度,即总高度为 4 * cardHeight + 3 * cardGap

此外,我们需要指定在视口的“中心”应该是哪部分世界。在本例中,“中心”应该位于屏幕的顶部中心,对应游戏世界中的坐标为 [(7 * cardWidth + 8 * cardGap) / 2, 0]

这种取景器位置和锚点的选择,是因为我们希望在游戏尺寸变得过宽或过高时,内容能合适响应。如果过宽,内容需要在屏幕上居中;如果过高,内容需要顶部对齐。

    camera.viewfinder.visibleGameSize =
           Vector2(cardWidth * 7 + cardGap * 8, 4 * cardHeight + 3 * cardGap);
    camera.viewfinder.position = Vector2(cardWidth * 3.5 + cardGap * 4, 0);
    camera.viewfinder.anchor = Anchor.topCenter;

我们的基础游戏结构已经创建完成,接下来的一切将基于这个结构。在下一步中,我们将学习如何渲染纸牌对象,这些是游戏中最重要的视觉对象。


教程翻译自 Flame 官方文档:Klondike 教程

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