我正在参加掘金社区游戏创意投稿大赛个人赛,详情请看:游戏创意投稿大赛
预览
在线试玩
网页版:v.idoo.top/web/fallDow… (请用手机访问,暂未适配PC端)
安卓版:坠落.apk
简介
高中时,我经常到应用商店找一些休闲小游戏玩,其中有一款名叫《FallDown delux》的游戏,最受我的喜爱。每次刷机或者换新手机,我都会把它备份一遍,不舍得删掉。
后来我成了一名程序员,某天心血来潮:为什么不自己实现一个呢?
于是我十分激动地把它反编译掉,想从它的源码里学习下实现思路。
但是,天不遂人愿。
等我解开它的 apk 安装包才发现,原来这货是用 cocos2d-x 写的,我拿 apktool 反编译了个寂寞~
没办法,既然不能拿到人家的源码窥探一二,那就只能自己硬着头皮写一个出来了,最后成果如下:
实现细节
游戏规则
首先分析一下游戏规则:
- 小球需要在红线追赶上之前,不断通过楼层上的缺口,落到下一层
- 每个楼层的缺口个数、位置不同,随机生成
- 小球通过的楼层层数越多分数越高
- 在逃避红线追赶的时,小球也可以去“吃”楼层上随机放置的金币,获得额外加分
- 当达到一定分数时,小球等级升级,楼层更新颜色
- 红线追赶速度随小球通过楼层数增加而变快
- 当红线追赶上小球时,游戏结束
可以看到,目前我们需要解决的部分主要有:
- 楼层上缺口与金币的个数和位置的随机生成
- 红线到小球距离的测定与红线下降速度的调整
- 摄像机跟随小球使小球下落时仍然位于视野中央
- 分数等级系统与游戏结束的判定
- 监听屏幕点击事件操控小球移动方向
下面我们一起来实现一下各部分。
初始化
这里,我们选择 Flutter + Flame + Box2D 作为主要项目技术栈。
其中,Flame 是 Flutter 生态里的一个 2D 游戏引擎,负责游戏场景的渲染和事件循环等。
而 Box2D 则是一个物理引擎,负责物理世界的物体运动和碰撞检测等。
随机生成楼层与金币
首先我们将每行分成 21 个小格子,然后规定:
- 一个空缺长度或砖块长度为 3
- 每个楼层有 1-2 个空缺
- 两空缺之间至少相隔一个砖块
- 第一层只有一个空缺且位于中间
- 第一层没有金币生成
为进一步简化计算,我们不妨将 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),
);
});
}
// ...
}
红线位置与下落速度
根据游戏规则我们可知:
- 红线下落速度与小球下落高度成正比
- 红线始终位于屏幕顶部,不会超出屏幕视野范围,即小球与红线最大距离为半个屏幕的高度
可见,随着游戏的进行,难度会越来越大,
要想取得高分需要让小球下落的越来越快,直到红线追赶上小球,游戏结束。
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 的物理世界,沿屏幕竖直方向向下施加重力,规则如下:
- 小球水平方向不可滚出屏幕外部,即屏幕左右两边为物理边界(墙)
- 小球下落时始终位于屏幕中间,即摄像机跟随小球向下运动
- 超出视野的楼层会被及时回收内存资源
- 进入视野的下一个楼层会在进入前生成
很像把小球放入一个“无底洞”,让它自由落体,无限下落。
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」系列文章的基础部分尚未开始写作,我会每周抽出一些时间来逐步完善该系列,如果你感兴趣,不妨先保持关注,敬请期待。