我正在参加掘金社区游戏创意投稿大赛个人赛,详情请看:游戏创意投稿大赛
预览
在线试玩
网页版:v.idoo.top/mix (请用手机访问,暂未适配PC端)
安卓版:合成大瓜.apk
简介
时间回到 2021 年春节前夕,彼时一款名为《合成大西瓜》的小游戏火了,每天都能在微博热榜上看到它的身影,朋友圈到处充斥着好友炫耀分数和各种骚操作,一时间欢乐极了。
仔细分析一下,这个小游戏并不复杂,可以自己动手实现一个,吃自己做出来的“瓜”应该挺香的。
所以 2021 年春节年假,我在家前后花了大概三天三夜的时间(一直写到大年三十的晚上~),最终整了个 plus 版的合成大西瓜出来,我们不妨先称之为「合成大瓜」吧。
相比原版的合成大西瓜,我们的这个「合成大瓜」还更“人性化”的支持了以下玩法:
- 支持自定义背景图(想换啥背景,都随便你哈)
- 支持修改图片素材(想把瓜的图片换成 idol 的头像?随便换喽)
- 支持重力感应操控(听说天下没有合不成的大西瓜,如果有那就多摇两下手机~)
- 支持反向合成小瓜(大瓜合成小瓜,逆向思维属于是,哈哈哈)
- 支持只生成小/大瓜(每一个瓜掉下来都是一样的,还有什么理由合成不了终极大瓜?)
- 内置多套游戏主题(水果/表情/校徽随便你选,听说 emoji 和 985 主题的终极 boss 有惊喜哦,坏笑.jpg)
看到这里,大家对这“瓜”还满意不?下面让我带诸位一起整个属于自己的“瓜”出来吧,保熟!
实现细节
游戏规则
老规矩,先分析一下游戏规则:
- 玩家点击屏幕上的任意位置放置水果
- 当两个相同的水果碰到一起时会合成更高等级的水果
- 通过不断合成最终会合成大西瓜, 玩家胜利
- 水果超过屏幕顶部的警戒线时,游戏结束
可以看到,游戏规则还是比较简单的,目前我们需要解决的部分主要有:
- 同等级水果间的碰撞检测
- 分数等级系统与游戏结束的判定
- 同等级水果碰撞升级时的缩放动画与粒子动效
- 监听屏幕点击事件操控水果下落位置
下面我们一起来实现一下各部分。
初始化
这里,我们继续选择 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」系列文章的基础部分尚未开始写作,我会每周抽出一些时间来逐步完善该系列,如果你感兴趣,不妨先保持关注,敬请期待。