趣学 Flutter「小游戏」:坠落

2,139 阅读6分钟

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

预览

在线试玩

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

安卓版:坠落.apk

简介

高中时,我经常到应用商店找一些休闲小游戏玩,其中有一款名叫《FallDown delux》的游戏,最受我的喜爱。每次刷机或者换新手机,我都会把它备份一遍,不舍得删掉。

后来我成了一名程序员,某天心血来潮:为什么不自己实现一个呢?

于是我十分激动地把它反编译掉,想从它的源码里学习下实现思路。

但是,天不遂人愿。

等我解开它的 apk 安装包才发现,原来这货是用 cocos2d-x 写的,我拿 apktool 反编译了个寂寞~

没办法,既然不能拿到人家的源码窥探一二,那就只能自己硬着头皮写一个出来了,最后成果如下:

实现细节

游戏规则

首先分析一下游戏规则:

  1. 小球需要在红线追赶上之前,不断通过楼层上的缺口,落到下一层
  2. 每个楼层的缺口个数、位置不同,随机生成
  3. 小球通过的楼层层数越多分数越高
  4. 在逃避红线追赶的时,小球也可以去“吃”楼层上随机放置的金币,获得额外加分
  5. 当达到一定分数时,小球等级升级,楼层更新颜色
  6. 红线追赶速度随小球通过楼层数增加而变快
  7. 当红线追赶上小球时,游戏结束

可以看到,目前我们需要解决的部分主要有:

  1. 楼层上缺口与金币的个数和位置的随机生成
  2. 红线到小球距离的测定与红线下降速度的调整
  3. 摄像机跟随小球使小球下落时仍然位于视野中央
  4. 分数等级系统与游戏结束的判定
  5. 监听屏幕点击事件操控小球移动方向

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

初始化

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

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

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

随机生成楼层与金币

首先我们将每行分成 21 个小格子,然后规定:

  1. 一个空缺长度或砖块长度为 3
  2. 每个楼层有 1-2 个空缺
  3. 两空缺之间至少相隔一个砖块
  4. 第一层只有一个空缺且位于中间
  5. 第一层没有金币生成

为进一步简化计算,我们不妨将 21 个小格子按 3 个一组分成 7 组,

然后从中随机选出 1-2 个不连续的组作为空缺位置。

/// 分组个数
const kGroups = 7;

/// 每组宽3个box
const kGroupWidth = 3;

/// 每层21个box
const kBoxsPerFloor = 21;

/// 楼层高度
const kFloorHeight = 20;

/// 砖块宽度
const kBoxWidth = 30;

/// 随机取数组中元素
pickOne(List items) => items[Random().nextInt(items.length)];

/// 创建一个楼层
void generateFloor(Box2DComponent box, double floor) {
  // 当前楼层高度
  double floorHeight = floor * kFloorHeight;
  // 缺口下标
  List<int> blankIndexs = [];
  // 剩余可用的下标
  List<int> remainIndexs = 0.rangeTo(kGroups - 1);
  // 砖块位置
  List<int> boxPositions = [];
  // 缺口位置
  List<int> blankPositions = [];
  // 砖块
  var boxs = <Box>[]..length = kBoxsPerFloor;
  // 金币
  var coins = <Coin>[]..length = kBoxsPerFloor;

  if (floor == 0) {
    // 第一层障碍只有中间一个缺口
    blankIndexs = [(kGroups - 1) / 2];
  } else {
    // 生成随机空缺个数(至少一个)
    int blankCounts = pickOne([1, 2]);
    []
      ..length = blankCounts
      ..forEach((_) {
        final blankIndex = pickOne(remainIndexs);
        blankIndexs.add(blankIndex);
        blankPositions.addAll((3 * blankIndex).rangeTo(3 * (blankIndex + 1)));
        remainIndexs.removeRange(blankIndex - 2, blankIndex + 1);
      });
  }
  //填充空缺位置左右两边
  for (final blank in blankPositions) {
    //左边
    if (blank - 1 > -1 && !blankPositions.contains(blank - 1)) {
      boxs[blank - 1] = Box.right(
        box,
        Vector2(kBoxWidth * (blank - 1), floorHeight),
      );
    }
    //右边
    if (blank + 1 < kBoxsPerFloor && !blankPositions.contains(blank + 1)) {
      boxs[blank + 1] = Box.left(
        box,
        Vector2(kBoxWidth * (blank + 1), floorHeight),
      );
    }
  }
  // 剩余位置填充砖块
  for (int i in 0.rangeTo(kBoxsPerFloor - 1)) {
    if (!blankIndexs.contains(i) && boxs[i] == null) {
      boxs[i] = Box.center(
        box,
        Vector2(kBoxWidth * i, floorHeight),
      );
      boxPositions.add(i);
    }
  }
  //填充金币
  if (floor != 0) {
    //生成随机金币数
    int coinCounts = Random().nextInt(boxPositions.length - 1);
    []
      ..length = coinCounts
      ..forEach((_) {
        //随机生成空缺位置
        final coinPosition = pickOne(boxPositions);
        boxPositions.remove(coinPosition);
        coins[coinPosition] = Coin(
          box,
          Vector2(kBoxWidth * coinPosition, floorHeight - kBoxWidth),
        );
      });
  }

  // ...
}

红线位置与下落速度

根据游戏规则我们可知:

  1. 红线下落速度与小球下落高度成正比
  2. 红线始终位于屏幕顶部,不会超出屏幕视野范围,即小球与红线最大距离为半个屏幕的高度

可见,随着游戏的进行,难度会越来越大,

要想取得高分需要让小球下落的越来越快,直到红线追赶上小球,游戏结束。

class Deadline {

    ...

    /// 更新 deadline
    @override
    void update(double dt) {
        super.update(dt);
        if (deadline.positionY > ball.positionY) {
            // deadline y轴位置超过小球顶部,游戏结束
            gameOver();
        } else {
            // 更新 deadline 位置
            deadline.positionY -= speed * ball.size;
            if (deadline.positionY < 0) {
            // deadline 始终在屏幕视野范围内
            deadline.positionY = 0;
            }
            if (deadline.positionY > ball.positionY) {
            // deadline 追赶上小球后,不再继续往下落
            deadline.positionY = ball.positionY + 1;
            }
        }
        //更新速度
        if (score % 20 == 0 && lastScore != score) {
            // 每增加20分,deadline 下落加速,难度增大
            lastScore = score;
            speed += 0.01;
        }
    }
}

摄像机视野跟随小球

整个游戏场景可以看做一个 2D 的物理世界,沿屏幕竖直方向向下施加重力,规则如下:

  1. 小球水平方向不可滚出屏幕外部,即屏幕左右两边为物理边界(墙)
  2. 小球下落时始终位于屏幕中间,即摄像机跟随小球向下运动
  3. 超出视野的楼层会被及时回收内存资源
  4. 进入视野的下一个楼层会在进入前生成

很像把小球放入一个“无底洞”,让它自由落体,无限下落。

class Ball {

    ...

    /// 更新小球
    @override
    void update(double dt) {
        super.update(dt);
        // 获取小球物理世界的位置并映射到屏幕上
        ball.positionY = ball.viewport.getWorldToScreen(ball.center).y;
        if (positionY > game.cameraDistance) {
            //等小球来到视野中央再开始相机跟踪,否则小球位置会出现瞬移
            box.cameraFollow(body, vertical: .0);
        }
        // 小球冲量值
        final linearImpulse = ball.gravity * //重力加速度
            ball.density * //密度
            3.14 * //pi
            (ball.size.width / 2) * //r^2
            (ball.size.width / 2);
        // 在小球中间位置向下施加恒定冲量
        ball.applyLinearImpulse(
            Vector2(0, -linearImpulse),
            ball.center,
        );
        if (movingLeft) {
            // 小球左移,在小球中间位置向左施加恒定冲量
            ball.applyLinearImpulse(
            Vector2(-linearImpulse, 0),
            ball.center,
            );
        }
        if (movingRight) {
            // 小球右移,在小球中间位置向右施加恒定冲量
            ball.applyLinearImpulse(
            Vector2(linearImpulse, 0),
            ball.center,
            );
        }
    }
}

/// 屏幕内可容纳楼层个数
const kScreenFloors = 10;

class Floor {

    ...

    /// 更新楼层
    @override
    void update(double dt) {
        super.update(dt);
        // 清空屏幕之外的楼层
        floors.where((b) => b.positionY < 0).toList().forEach((floor) {
            floors.remove(floor); //删除楼层
        });
        // 添加新楼层
        double lastPositionY;
        while (floors.length < kScreenFloors) {
            // 最后一个楼层位置
            lastPositionY ??= floors.last.positionY;
            // 下一个楼层位置
            lastPositionY += floorHeight;
            // 添加楼层
            addFloor(lastPositionY);
        }
    }
}

小球移动方向操控

这里有两种操控小球移动方向的方法,一种是点击屏幕左右区域,另一种是重力感应。

lass MyGame extends Box2DGame with HasTapableComponents, KeyboardEvents {
  //加速度传感器x轴状态
  double sensorX = 0;
  // 加速度传感器订阅流
  StreamSubscription<dynamic> _stream;
  // 是否使用重力操控
  bool _useGravity = false;
  // 如果使用重力操控且不是web端,则开启重力操控模式
  get useGravity => _useGravity && !kIsWeb; // web端不支持重力操控

  init() async {
    if (useGravity) {
      // 开启加速度传感器监听
      _stream = accelerometerEvents.listen((AccelerometerEvent event) {
        sensorX = event.x;
      });
    }
  }

  dispose() {
    if (useGravity) {
      // 关闭流
      _stream?.cancel();
    }
  }

  @override
  void update(double dt) {
    //...

    //重力感应操控方向
    if (useGravity) {
      if (sensorX) {
        ball.startMoveLeft();
      } else {
        ball.stopMoveLeft();
      }
    }
  }

  @override
  void onKeyEvent(event) {
    if (event is RawKeyDownEvent) {
      if (event.data.keyLabel == 'ArrowLeft') {
        ball.startMoveLeft();
      } else {
        ball.stopMoveLeft();
      }
    }
  }

  @override
  void onTapDown(pointerId, TapDownDetails details) {
    if (details.globalPosition.dx < screenSize.width / 2) {
      ball.startMoveLeft();
    } else {
      ball.startMoveRight();
    }
  }

  @override
  void onTapUp(pointerId, TapUpDetails details) {
    if (details.globalPosition.dx < screenSize.width / 2) {
      ball.stopMoveLeft();
    } else {
      ball.stopMoveRight();
    }
  }
}

总结

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

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

PS:项目代码较为古老(我 2019 年的作品),彼时写代码还比较菜,仅供参考,大佬轻喷。

One more thing

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

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