本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
Flutter&Flame 游戏开发系列前言:
该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台 的 原生级 渲染框架,兼具 全端
跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。
第一季:30 篇短文章,快速了解 Flame 基础。
[已完结]
第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。
两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》 或 【github 项目首页】 查看。
通过资源图片,可以看出其中有很多的砖块、挡板的样式。我们可以进一步提升游戏玩法的复杂程度:
本篇将完成打砖块游戏中的金币和商店系统,如下所示道具商店中有 挡板 、符文 和 功能道具 三个种类的商品。点击商品时可以展示详情,进行购买。其中:
- 挡板 : 不同的挡板具有不同的特殊功能,购买后永久持有。
- 符文 :一次性持久道具,可以击碎关卡内对应样式的砖块。
- 功能道具:一次性持久道具,具有特定功能的。
商品列表 | 商品详情 |
---|---|
一、 关卡砖块设计
为拓展玩法,首先,我们对砖块的样式进行完善。这里提取了如下三类共 15 种砖块,在一个关卡内随机出现一种类型的五种砖块。结合道具中的碎石符文,可以击碎关卡内对应颜色的砖块:
1. 关卡内砖块随机样式
下图左侧是之前的单一砖块,右图是第一类 5种砖块的随机组合。其中每个关卡中砖块的展示与否是固定的,只是砖块的图片资源不同:
单一砖块 | |
---|---|
此时,我们需要对 Bricks 砖块构件进行优化。在功能需求中,要根据砖块类型批量删除,此时需要为 Bricks 增加标识;可以将 图片资源 视为标识,通过构造进行传入;刚好在加载时也可以作为砖块的图片资源:
dart
---->[lib/bricks/06/heroes/bricks.dart]----
class Brick extends SpriteComponent with HasGameRef<BricksGame> {
final int id;
final String src;
Brick(this.id, this.src);
@override
FutureOr<void> onLoad() {
sprite = game.loader[src];
add(RectangleHitbox());
return super.onLoad();
}
@override
void onRemove() {
super.onRemove();
game.world.checkSuccess();
}
}
2. 随机类型随机砖块资源
Bricks 构件在 BricksManager 中创建时,可以通过随机数来随机得到图片资源。这里希望每个关卡中只出现一种类型的五种,而不是 15 种图片随机出现,这样太过杂乱。如下通过二维数组 kTypeBrickList
维护类型列表,每种类型中有五个图片资源:
dart
---->[lib/bricks/06/heroes/bricks.dart]----
static const List<String> kPureBricks = [
"Colored_Blue-64x32.png",
"Colored_Green-64x32.png",
"Colored_Yellow-64x32.png",
"Colored_Purple-64x32.png",
"Colored_Orange-64x32.png",
];
static const List<String> kWallBricks = [
"Colored_Blue_Block-64x32.png",
"Colored_Green_Block-64x32.png",
"Colored_Yellow_Block-64x32.png",
"Colored_Purple_Block-64x32.png",
"Colored_Orange_Block-64x32.png",
];
static const List<String> kTexturedBricks = [
"Textured_Brick_03-64x32.png",
"Textured_Brick_02-64x32.png",
"Textured_Stone_02-64x32.png",
"Textured_Stone_03-64x32.png",
"Textured_Brick_04-64x32.png",
];
static const List<List<String>> kTypeBrickList = [
kPureBricks,
kWallBricks,
kTexturedBricks
];
这样每个关卡中,让随机出现某一类的砖块,如下所示:
纯色 | 砖块 | 石块 |
---|---|---|
二、挡板的功能设计
目前一共有六种挡板,游戏中将为这六种挡板赋予特殊能力;自左到右依次是:
粉红幸运
: 关卡道具出现概率 +5%
蓝色雷霆
: 每击碎10个砖块,击碎横竖方向砖块
紫色秘宝
: 每击碎10个方块,获得随机道具
黄色宝盆
: 砖块击碎时,获取金币概率 +10%
红色嗜血
: 每击碎10个砖块,随机消除一类砖块
天蓝双星
:关卡开始后,获得 +1 球道具
1. 挡板类型
挡板一个有 6 种类型,每种类型有正常和延展的两种图片。这里通过枚举 PaddleType 进行维护:
dart
---->[lib/bricks/06/heroes/paddle.dart]----
enum PaddleType{
pink('Paddle_C_Red_96x28.png','Paddle_C_Red_192x28.png'),
blue('Paddle_C_Blue_96x28.png','Paddle_C_Blue_192x28.png'),
purple('Paddle_B_Purple_96x28.png','Paddle_B_Purple_192x28.png'),
yellow('Paddle_B_Yellow_96x28.png','Paddle_B_Yellow_192x28.png'),
red('Paddle_A_Red_96x28.png','Paddle_A_Red_192x28.png'),
azure('Paddle_A_Blue_96x28.png','Paddle_A_Blue_192x28.png'),
;
final String src;
final String expandSrc;
const PaddleType(this.src,this.expandSrc);
}
2. 应用配置信息的维护
挡板一旦购买,会永久生效。所以 是否已经购买
、当前激活挡板
类型,这两个数据需要进行持久化存储。之前游戏数据配置通过 GameConfig
类记录,可以在其中添加两个数据用于记录挡板信息:
dart
---->[lib/bricks/06/config/game_config.dart#GameConfig]----
class GameConfig {
// 略同...
// 挡板类型
final PaddleType paddleType;
// 已购买的挡板索引列表
final List<int> activePaddles;
factory GameConfig.fromMap(dynamic map) {
return GameConfig(
// 略同...
activePaddles: map['activePaddles'].map<int>((e)=>e as int).toList() ?? [5],
paddleType: PaddleType.values[map['paddleType'] ?? 5],
);
之前数据持久化的工作在 GameConfigManager 处理,通过 xml 配置文件存储数据。这里在其中提供 switchPaddleType
和 buyPaddleSuccess
方法,分别处理切换挡板类型,和购买成功后的数据存储:
dart
---->[lib/bricks/06/config/game_config.dart#GameConfigManager]----
/// 切换挡板样式
Future<void> switchPaddleType(PaddleType type) {
config = config.copyWith(paddleType: type);
return saveConfig();
}
/// 购买挡板
Future<void> buyPaddleSuccess(PaddleType type) {
List<int> actives = [type.index, ...config.activePaddles];
config = config.copyWith(activePaddles: actives);
return saveConfig();
}
3. 挡板构建的代码处理
为了方便访问挡板相关的配置信息,在 BricksGame 中提供了 paddleType
和 buyPaddles
两个 get 方法,分别获取 当前挡板 和 已购买的挡板列表:
dart
class BricksGame extends FlameGame<PlayWorld> with KeyboardEvents, HasCollisionDetection {
/// 略同...
// 当前挡板
PaddleType get paddleType => configManager.config.paddleType;
// 已购买的挡板列表
List<PaddleType> get buyPaddles => configManager.config.activePaddles
.map((e) => PaddleType.values[e])
.toList();
// 切换挡板
void switchPaddle(String src) {
PaddleType type = PaddleType.values.singleWhere((e) => e.src == src);
configManager.switchPaddleType(type);
world.paddle.switchType(src);
}
此时 Paddle 中的图片资源,取用 game.paddleType
即可。切换挡板类型,对于 Paddle 来说就是更新图片资源:
dart
class Paddle extends SpriteComponent with HasGameRef<BricksGame> , CollisionCallbacks {
void expand(){
String src = game.paddleType.expandSrc;
sprite = game.loader[src];
}
void expandEnd(){
String src = game.paddleType.src;
sprite = game.loader[src];
}
void switchType(String src) {
sprite = game.loader[src];
}
三、道具商店界面构建
道具商店中的界面如下,包括顶部标题、挡板商铺、碎石符文、功能道具四个区域。其中列出的售卖品称之为商品(Goods) , 待售商品在点击时,可以弹出详情介绍:
挡板 | 符文 |
---|---|
1. 商品的封装和管理
三类商品在视图中的表现有所差异,比如挡板只能购买一次,而且已购买的挡板可以切换激活状态。这里通过 GoodsType
枚举表示商品类型,定义 Goods
类承载商品的通用数据:
dart
---->[lib/bricks/06/overlays/shop_page/goods.dart]----
enum GoodsType {
paddle, /// 挡板
rune, /// 符文
function, // 功能道具
}
class Goods {
final String title; // 标题
final String desc; // 描述
final String src; // 资源图片
final GoodsType type; // 类型
final int? coin; // 所需金币
final int? crystal; // 所需水晶
···
商品的具体数据列表,这里通过 json 字符串进行维护,其中是商品信息的列表数据。在 loadGoods
方法中解析 json 文件,为 _goods
列表赋值;并提供 goods
方法根据类型查询对应类型的商品列表:
dart
---->[lib/bricks/06/overlays/shop_page/goods_mamager.dart]----
class GoodsManager {
List<Goods> _goods = [];
Goods? showMenuGoods;
List<Goods> goods(GoodsType type) =>
_goods.where((e) => e.type == type).toList();
Future<void> loadGoods() async {
String goodsStr = await rootBundle.loadString('assets/data/goods.json');
List data = jsonDecode(goodsStr) as List;
_goods = data.map(Goods.fromMap).toList();
}
}
这样,商品的数据层就准备完毕了,下面看一下视图构建。
2. 商品容器的构建
如下所示,每个商品对应一个容器,上面是商品的图片,下面是商品的售价或信息。我们将这个容器单独封装为 GoodsCell
组件。
条目容器背景使用图片,另外我们希不同的尺寸,图片以九宫缩放。可以使用之前的封装的 NineImageWidget , 另外容器中的具体内容、底部信息、点击回调事件,通过构造方法由使用者提供:
dart
class GoodsCell extends StatelessWidget {
final BricksGame game;
final Goods goods;
final Size size;
final Widget child;
final Widget bottom;
final ValueChanged<Goods> onSelectGoods;
const GoodsCell({
super.key,
required this.goods,
required this.game,
required this.size,
required this.onSelectGoods,
required this.child,
required this.bottom,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onSelectGoods(goods),
child: SizedBox(
height: size.height,
width: size.width,
child: NineImageWidget(
expandZone: const Rect.fromLTWH(4, 4, 4, 4),
image: game.loader['Cell01.png'].image,
child: _buildContent(),
),
),
);
}
Widget _buildContent(){
const EdgeInsets pd = EdgeInsets.symmetric(vertical: 4);
return Center(
child: Column(
children: [
Expanded(child: Center(child: child)),
const Divider(height: 1),
Padding(padding: pd, child: bottom),
],
),
);
}
}
3. 挡板商品列表视图构建
挡板商品在展示时,底部有三种状态:
- 如果是当前在使用的挡板,展示 正在使用 。
- 如果已经购买过挡板,展示 已购买 。
- 如果没有购买过挡板,展示 需要购买的金币 。
已购买 | 正在使用 | 未购买 |
---|---|---|
这里通过 PaddleGoodsItem 展示挡板商品,其中传入 active
是否是当前使用的挡板;以及 hasPaddle
表示是否已经购买:
dart
---->[lib/bricks/06/overlays/shop_page/goods_item/paddle_goods_item.dart]----
class PaddleGoodsItem extends StatelessWidget {
final bool active;
final bool hasPaddle;
final BricksGame game;
final Goods goods;
final ValueChanged<Goods> onSelectGoods;
const PaddleGoodsItem({
super.key,
required this.game,
required this.goods,
required this.onSelectGoods,
required this.active,
required this.hasPaddle,
});
视图构建逻辑在 build 方法中,使用上面的 GoodsCell 组件提供整体的布局结构。bottom
对应的组件,单独封装为 buildBottom
处理。对于已经购买过的挡板,点击时不希望弹出菜单框,这时可以通过 Tooltip
组件展示具体功能:
dart
@override
Widget build(BuildContext context) {
Widget child = GoodsCell(
size: const Size(90, 90),
goods: goods,
game: game,
onSelectGoods: onSelectGoods,
bottom: buildBottom(hasPaddle, active),
child: SizedBox(
width: 64,
height: 64 * 28 / 96,
child: SpriteWidget(sprite: game.loader[goods.src]),
),
);
if (hasPaddle) {
child = Tooltip(message: goods.desc, child: child);
}
return child;
}
buildBottom 方法中,根据 has
和 active
决定底部的构建逻辑即可:
dart
Widget buildBottom(bool has, bool active) {
const TextStyle white = TextStyle(color: Colors.white);
const TextStyle blue = TextStyle(color: Colors.blue);
if (active) return const Text("正在使用", style: blue);
if (has) return const Text("已购买", style: white);
String coin = 'assets/images/break_bricks/coin.png';
return Wrap(
spacing: 2,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Image.asset(coin, width: 14, height: 14),
Text(goods.coin.toString(), style: white),
],
);
}
挡板的单体构建完毕,接下来需要通过商品的数据,遍历创建多个商品。如下,通过 Wrap 组件包裹商品列表条目,使用 _buildItem
方法根据 Goods 数据构建条目:
dart
class PaddleShop extends StatefulWidget {
final BricksGame game;
const PaddleShop({super.key, required this.game});
@override
State<PaddleShop> createState() => _PaddleShopState();
}
class _PaddleShopState extends State<PaddleShop> {
@override
Widget build(BuildContext context) {
const TextStyle style = TextStyle(fontSize: 16, color: Colors.white);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 24.0, vertical: 12),
child: Text("挡板商铺", style: style),
),
Center(
child: Wrap(
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
children: widget.game.goodsManager
.goods(GoodsType.paddle)
.map(_buildItem)
.toList(),
),
),
],
);
}
在使用 PaddleGoodsItem
时,我们需要明确当前商品是否已购买,或是当前激活项。如下所示:
- 根据
game.buyPaddles
获取图片资源列表,校验当前商品图片资源是否在其中,可以得到是否购买。 - 根据
game.paddleType
的图片资源,是否和商品图片资源限定。确定是否为当前激活项。 - 在回调事件中,如果已经购买且非激活项,调用
switchPaddle
切换挡板类型。 - 在回调事件中,如果未购买,弹出 GoodsInfoMenu 展示商品信息弹框。
dart
Widget _buildItem(Goods goods) {
List<String> srcList = widget.game.buyPaddles.map((e) => e.src).toList();
bool hasPaddle = srcList.contains(goods.src);
bool active = widget.game.paddleType.src == goods.src;
return PaddleGoodsItem(
hasPaddle: hasPaddle,
active: active,
goods: goods,
onSelectGoods: (goods) {
if (active) return;
if (hasPaddle && !active) {
widget.game.switchPaddle(goods.src);
setState(() {});
} else {
widget.game.goodsManager.showMenuGoods = goods;
widget.game.overlays.add("GoodsInfoMenu");
}
},
game: widget.game,
);
}
符文和功能道具的商品处理方式和上面类似,就不过多赘述了,详情可见源码。
四、金币的获取与购买商品
目前作为一个简单的打砖块游戏,金币获取的唯一方式是击碎砖块时,概率掉落。挡板接到金币时,金币数 +1 。如下图所示:
金币掉落 | 金币拾取 |
---|---|
1. 金币掉落代码处理
和道具掉落类似,当击碎砖块时,随机概率产生金币并掉落。只不过道具是在开局时生成的,这里将金币在砖块击碎时随机掉落。首先准备一个会下落的 CoinComponent
表示金币构件:
dart
---->[lib/bricks/06/heroes/prop/prop.dart]----
class CoinComponent extends SpriteComponent with HasGameRef<BricksGame> {
CoinComponent({super.position});
double fallSpeed = 150;
@override
FutureOr<void> onLoad() {
sprite = game.loader['coin.png'];
anchor = Anchor.center;
size = Vector2(24, 24);
add(RectangleHitbox());
return super.onLoad();
}
@override
void update(double dt) {
if (fallSpeed == 0 || isRemoving) return;
y += dt * fallSpeed;
if (absolutePosition.y > kViewPort.height) {
removeFromParent();
}
super.update(dt);
}
}
砖块将要消失有多处需要处理,比如子弹、小球已经后面的道具都可以销毁砖块。在 PlayWorld 中封装 onBrickWillRemove
方法,方便统一处理砖块将要消失的逻辑,其中:
- [1]. 将 Brick 从世界中移除
- [2]. 如果有关卡内道具,则掉落。
- [3]. 触发 createCoin 方法,20% 概率掉落金币。
- [4]. 播放砖块击碎音效。
dart
---->[lib/bricks/06/bricks_game.dart#PlayWorld]----
void onBrickWillRemove(Brick brick) {
brick.removeFromParent();
propManager.fallOrNot(brick.id);
createCoin(brick.absolutePosition + brick.size / 2);
game.am.play(SoundEffect.uiSelect);
}
void createCoin(Vector2 position) {
if (game.probability(0.20)) {
CoinComponent coin = CoinComponent(position: position);
add(coin);
}
}
此时在小球碰撞砖块,以及子弹碰撞砖块时,触发 PlayWorld#onBrickWillRemove
方法即可:
2. 金币拾取和增加
金币的拾取和道具一样,通过检测挡板和金币的碰撞事件即可。获取金币后,将金币从世界中移除,并触发 game#getCoin 方法处理增加金币的业务逻辑:
BricksGame 中的 getCoin 方法,触发 configManager.addCoin
增加一个金币,并将数据持久化。然后触发顶部的金币构件更新数字:
dart
---->[lib/bricks/06/bricks_game.dart#BricksGame]----
void getCoin() {
configManager.addCoin();
world.titleBar.coin.updateCoin();
}
3. 金币购买商品
本篇最后看一下通过金币购买商品的逻辑。视图交互如下,当单击商品弹出菜单时,选择购买会获得商品,并且减少金币数:
购买挡板 | 购买道具 |
---|---|
这里在 BricksGame 中提供一个 buy
方法,用于处理购买商品。减去对应的金币数,并进行持久化存储。如果购买的是挡板,还需要进行挡板购买项的维护工作,在 GameConfigManager#buyPaddleSuccess
方法中处理,增加对应的挡板索引,并持久化记录:
dart
---->[lib/bricks/06/bricks_game.dart#BricksGame]----
void buy(Goods goods) {
if(goods.type==GoodsType.paddle){
String src = goods.src;
PaddleType type = PaddleType.values.singleWhere((e) => e.src==src);
configManager.buyPaddleSuccess(type);
}
configManager.addCoin(count: -(goods.coin??0));
world.titleBar.coin.updateCoin();
}
---->[lib/bricks/06/config/game_config.dart]----
/// 购买挡板
Future<void> buyPaddleSuccess(PaddleType type) {
List<int> actives = [type.index, ...config.activePaddles];
config = config.copyWith(activePaddles: actives);
return saveConfig();
}
到这里,金币的获取以及消费的流程就完成了。本篇还对打砖块的玩法做了进一步的升级,使其更具有可玩性。那购买的商品道具如何使用呢?下一篇将继续完善商品中道具功能,并支持背包盛纳道具,敬请期待 ~