趣学 Flutter「小游戏」:合成大瓜

3,149 阅读7分钟

我正在参加掘金社区游戏创意投稿大赛个人赛,详情请看:游戏创意投稿大赛

预览

在线试玩

网页版:v.idoo.top/mix (请用手机访问,暂未适配PC端)

安卓版:合成大瓜.apk

简介

时间回到 2021 年春节前夕,彼时一款名为《合成大西瓜》的小游戏火了,每天都能在微博热榜上看到它的身影,朋友圈到处充斥着好友炫耀分数和各种骚操作,一时间欢乐极了。

仔细分析一下,这个小游戏并不复杂,可以自己动手实现一个,吃自己做出来的“瓜”应该挺香的。

所以 2021 年春节年假,我在家前后花了大概三天三夜的时间(一直写到大年三十的晚上~),最终整了个 plus 版的合成大西瓜出来,我们不妨先称之为「合成大瓜」吧。

相比原版的合成大西瓜,我们的这个「合成大瓜」还更“人性化”的支持了以下玩法:

  1. 支持自定义背景图(想换啥背景,都随便你哈)
  2. 支持修改图片素材(想把瓜的图片换成 idol 的头像?随便换喽)
  3. 支持重力感应操控(听说天下没有合不成的大西瓜,如果有那就多摇两下手机~)
  4. 支持反向合成小瓜(大瓜合成小瓜,逆向思维属于是,哈哈哈)
  5. 支持只生成小/大瓜(每一个瓜掉下来都是一样的,还有什么理由合成不了终极大瓜?)
  6. 内置多套游戏主题(水果/表情/校徽随便你选,听说 emoji 和 985 主题的终极 boss 有惊喜哦,坏笑.jpg)

看到这里,大家对这“瓜”还满意不?下面让我带诸位一起整个属于自己的“瓜”出来吧,保熟

实现细节

游戏规则

老规矩,先分析一下游戏规则:

  1. 玩家点击屏幕上的任意位置放置水果
  2. 当两个相同的水果碰到一起时会合成更高等级的水果
  3. 通过不断合成最终会合成大西瓜, 玩家胜利
  4. 水果超过屏幕顶部的警戒线时,游戏结束

可以看到,游戏规则还是比较简单的,目前我们需要解决的部分主要有:

  1. 同等级水果间的碰撞检测
  2. 分数等级系统与游戏结束的判定
  3. 同等级水果碰撞升级时的缩放动画与粒子动效
  4. 监听屏幕点击事件操控水果下落位置

下面我们一起来实现一下各部分。

初始化

这里,我们继续选择 Flutter + Flame + Forge2D 作为主要项目技术栈。

其中,Flame 是 Flutter 生态里的一个 2D 游戏引擎,负责游戏场景的渲染和事件循环等。

而 Forge2D 则是一个物理引擎,负责物理世界的物体运动和碰撞检测等。

碰撞检测

游戏中涉及到的碰撞主要分成两种:一是水果与地面的碰撞,二是水果与水果之间的碰撞。

我们先来定义下水果(球体)与地面(墙体)的基本属性:

/// 墙体
class Wall extends BodyComponent {
  final Vector2 start;
  final Vector2 end;
  /// 墙面所在的边,一共有上下左右4面
  final int side;

  /// 一面墙即由起点连接至终点而成的一条线
  Wall(this.start, this.end, this.side);

  @override
  Body createBody() {
    final shape = PolygonShape();
    shape.setAsEdge(start, end);

    final fixtureDef = FixtureDef()
      ..shape = shape
      ..restitution = 0.0
      ..friction = 0.1;

    final bodyDef = BodyDef()
      ..userData = this // 检测碰撞
      ..position = Vector2.zero()
      ..type = BodyType.STATIC; // 固定物体,始终保持静止

    return world.createBody(bodyDef)..createFixture(fixtureDef);
  }
}

/// 水果(球体)
class Ball extends SpriteBodyComponent {
  /// 等级
  int level;

  /// 半径
  double _radius;

  /// 下落位置
  Vector2 fallPosition;

  /// 是否正在水平移动
  bool moving = true;

  /// 是否开始竖直下落
  bool isFalling;

  /// 是否已落地或与其他物体相撞
  bool landed = false;

  /// 是否升级
  bool levelUp = false;

  /// 是否remove
  bool removed = false;

  /// 是否正在膨胀成更高等级的水果
  bool bouncing = false;

  ...

}

现在物体有了,下面继续定义物体之间的碰撞检测。

/// 水果与地面相撞
class BallWallContactCallback extends ContactCallback<Ball, Wall> {
  /// 碰撞开始
  @override
  void begin(Ball ball, Wall wall, Contact contact) {
    if (wall.side != 3) {
        return; // 非地面,无反应
    }
    if (!ball.landed) {
      // 水果落地
      ball.landed = true;
      // 播放落地音效
      AudioTool.fall();
    }
  }
}

/// 水果之间相撞
class BallBallContactCallback extends ContactCallback<Ball, Ball> {
  /// 碰撞开始
  @override
  void begin(Ball ball1, Ball ball2, Contact contact) {
    // 水果落地
    ball1.landed = true;
    ball2.landed = true;
    if (ball1.level == ball2.level) {
      // 两相同等级的水果相撞,合成更高等级的水果
      if (ball1.position.y < ball2.position.y) {
        // 移除下面的水果
        ball1.removed = true;
        // 升级上面的水果
        ball2.levelUp = true;
      } else {
        ball2.removed = true;
        ball1.levelUp = true;
      }
    }
  }
}

粒子动效

仔细观察游戏画面可知,在水果合成时会伴随“炸裂”的动效,这里我们可以使用 Flame 内置的粒子系统来模拟这一过程。

class BloomPartcle {

  ...

  // 生成一个加速粒子动效
  AcceleratedParticle generate({
    Offset position,
    Offset angle,
    double speed,
    double radius,
    Color color,
  }) {
    return AcceleratedParticle(
      position: position,
      speed: angle * speed,
      acceleration: angle * radius,
      child: ComputedParticle(
        renderer: (canvas, particle) => canvas.drawCircle(
          Offset.zero,
          particle.progress * 5,
          Paint()
            ..color = Color.lerp(
              color,
              Colors.white,
              particle.progress * 0.1,
            ),
        ),
      ),
    );
  }

  /// 在指定位置展示粒子动效
  void show(Offset position, double radius) {
    // 随机生成粒子数量
    final n = randomCount(radius);
    // 随机生成粒子颜色
    final color = randomColor(radius);
    gameRef.add(
      ParticleComponent(
        particle: Particle.generate(
          count: n,
          lifespan: randomTime(radius), // 随机生成粒子持续时长
          generator: (i) {
            final angle = randomAngle((2 * pi / n) * i); // 随机生成粒子喷射角度
            return generate(
              position: position,
              angle: Offset(sin(angle), cos(angle)),
              radius: randomRadius(radius), // 随机生成粒子喷射半径
              speed: randomSpeed(radius), // 随机生成粒子喷射速度
              color: color,
            );
          },
        ),
      ),
    );
  }
}

水果下落

水果下落分成两步:第一步水平移动到下落点,第二步自由落体。

/// 水果下落控制器
class UpdateBallsFalling extends Component with HasGameRef<MyGame> {
  @override
  void update(double t) {
    // 找到并遍历所有尚未开始自由落体阶段的水果
    gameRef.components
        .where((e) => e is Ball && !e.isFalling && b.moving)
        .forEach((ball) {
      Ball b = ball;
      final p = b.position;
      // 预期的下落位置
      final fp = b.fallPosition;
      // 屏幕宽度
      final width = gameRef.viewport.vw(100);
      // 下落位置在屏幕左侧
      final left = fp.x < gameRef.viewport.center.x;
      // 下落位置在屏幕中央
      final center = (fp.x - gameRef.viewport.center.x).abs() < gameRef.viewport.vw(5);
      if (!center && b.body.linearVelocity.x == 0) {
        // 水果初始速度为0,第一阶段让水果在水平方向上匀速运动,直到到达目标下落点
        b.body.linearVelocity = Vector2((left ? -1 : 1) * gameRef.viewport.size.velocitySize, 0);
      }
      // 水果到达目标下落点,进入第二阶段开始自由落体
      if (center ||
          (left && (p.x < b.radius || p.x < fp.x)) ||
          (!left && (width - p.x < b.radius || p.x > fp.x))) {
        // 首先移除原来的水果
        gameRef.remove(ball);
        // 然后在原位置创建新的水果替代旧水果,使其自由落体
        gameRef.add(Ball.create(gameRef.viewport, position: p, level: b.level, canFall: true));
      }
    });
  }
}

水果升级

根据上面的分析,我们继续看下水果升级的过程:

class UpdateLevelUp extends Component with HasGameRef<MyGame> {
  @override
  void update(double t) {
    // 找到被标记为移除的水果,遍历销毁回收资源
    gameRef.components
        .where((e) => e is Ball && e.removed)
        .forEach(gameRef.remove);
    // 找到被标记为升级的水果,遍历升级
    gameRef.components.where((e) => e is Ball && e.levelUp).forEach((ball) {
      // 播放水果合成音效
      AudioTool.mix();
      Ball b = ball;
      // 更新分数
      GameState.updateScore(GameState.score + b.level);
      // 移除旧的水果
      gameRef.remove(ball);
      // 获取新等级水果的半径
      final radius = Levels.radius(b.level + 1);
      // 在原位置添加更高等级的水果替换旧水果
      gameRef.add(Ball.create(
        gameRef.viewport,
        position: b.position..y += (radius - b.radius),
        level: b.level + 1,
        canFall: true,
        landed: true,
        bounce: true,
      ));
      // 播放合成粒子动效
      BloomPartcle(gameRef).show(b.position.toOffset(), radius);
      // 是否为最终等级(boss)
      final isLastLevel = Levels.isLastLevel(b.level + 1);
      if (isLastLevel) {
        // 合成出了最高等级的水果,玩家胜利
        GameLife(gameRef).win();
      }
    });
  }
}

玩家交互

在「合成大瓜」里玩家操控水果下落有两种交互模式,一种是点击屏幕,另一种是重力感应。

有意思的是,对于第二种重力感应模式,我们的实现思路也是按照改变重力来的。

不过有一点需要注意,合成重力在竖直方向的分量应当始终向下,防止水果反向滚回屏幕顶部 🙃

class UpdateGravity extends Component with HasGameRef<MyGame> {
  /// 更新物理世界的重力
  void changeGravity(Vector2 gravity) => gameRef.world.setGravity(gravity);

  /// 屏幕倾斜程度
  double get tiltDegree {
    final x = SensorTool.xyz.x;
    if (x.abs() > 1) {
      // 屏幕向左右倾斜
      return -2 * (x / 10);
    }
    // 屏幕保持竖直
    return 0;
  }

  @override
  void update(double t) {
    // 重力大小
    final size = gameRef.size.gravitySize;
    // 普通的竖直向下的重力
    final gravity = Vector2(0, -1 * size);
    // 重力感应模式开启
    if (GameState.gameSetting.gravity) {
      // 由手机屏幕偏移带来的重力偏移分量
      final offset = Vector2(size, 0) * tiltDegree;
      // 合成新的重力(竖直方向的重力分量始终向下,防止水果反向滚回屏幕顶部)
      final newGravity = gravity + offset;
      changeGravity(newGravity);
    } else {
      // 重力感应模式关闭,重力场变为普通的竖直向下的重力
      if (gameRef.world.getGravity() != gravity) {
        changeGravity(gravity);
      }
    }
  }
}

总结

以上就是整个游戏涉及到的所有实现要点了,相信聪明的你也可以自己动手实现这个简单的小游戏。

如果你对更细节的代码实现感兴趣,可以到 github.com/idootop/wat… 这里查阅,拒绝白嫖,求个 Star 🌟

One more thing

趣学 Flutter「小游戏」,是「趣学 Flutter」系列文章的最后一个章节,是在大家掌握了前面的 Flutter 开发基础之后,进行的趣味实践部分。

PS:目前「趣学 Flutter」系列文章的基础部分尚未开始写作,我会每周抽出一些时间来逐步完善该系列,如果你感兴趣,不妨先保持关注,敬请期待。