如果你还不了解Flame可以看这里:
见微知著,Flutter在游戏开发的表现及跨平台带来的优势
Flutter&Flame——TankCombat游戏开发(一)
Flutter&Flame——TankCombat游戏开发(二)
Flutter&Flame——TankCombat游戏开发(三)
Flutter&Flame——TankCombat游戏开发(四)
我们要实现一个坦克大战:
玩家控制蓝色坦克,出生于屏幕中间
绿色和黄色为敌军坦克,出生于屏幕四角(随机)
发射的炮弹可以击毁坦克
敌军坦克在被摧毁后,会随机重生,但总体敌军数量保持4个
坦克可以发射炮弹,并分别旋转坦克身体和炮塔
更多功能待发现…
一口吃不了一个胖子,我们将项目拆分,先实现背景、摇杆和绘制一辆坦克
摇杆主要借鉴自官方,如果你已经在官方的教程里学会了,可以略过此章
首先我们引入Flame插件
flame: ^0.24.0
之后添加背景图片资源文件:
assets/images/
开始代码部分,我们将main函数清空,如下:
main()async{
}
添加如下代码,(还是老规矩,代码多时我会将说明添加到注解里。)
void main()async{
//确保flutter启动成功
WidgetsFlutterBinding.ensureInitialized();
//为flame加载资源文件
loadAssets();
///设置横屏
await SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeRight,
DeviceOrientation.landscapeLeft
]);
///全面屏
await SystemChrome.setEnabledSystemUIOverlays([]);
//这个稍后解释
final TankGame tankGame = TankGame();
runApp(...)//这里下面详细交代
}
loadAssets();的代码如下,主要是加载图片资源以备开发时候的使用
void loadAssets(){
Flame.images.loadAll([
'new_map.webp',
]);
}
接下来的tankgame,我们需要说一下它的父类Game
Game是Flame的核心,也是我们游戏的驱动力,它内部有两个主要的方法,就是上篇文章提到的
render(Canvas c)和update(double t)
这里再贴一下官方的流程图:
我们实际开发时,需要继承它并在上面两个方法中做我们自己的处理,如这里的TankGame:
class TankGame extends Game{
@override
void render(Canvas canvas) {}
@override
void update(double t) {}
///resize 这里的方法在屏幕尺寸变动和第一次初始化时会调用,
///我们可以在这里获取到屏幕的尺寸
@override
void resize(Size size) {}
}
接下来看最后一行的runApp(…)
这里如app开发一样,是我们要加载widget的地方,可以看一下game里面有个widget变量,就是在这里面用的,不过现在我们先考虑一下布局。
通过观察,可以发现摇杆是悬浮于地图上方的,所以这里用stack比较合适。代码如下:
runApp(Directionality(textDirection: TextDirection.ltr,
child: Stack(
children: [
///我们将游戏内容如tank,地图等放在最底层
tankGame.widget,
//上层放摇杆
Column(
children: [
//这个widget可以将摇杆挤在底部,内部是一个Expanded
Spacer(),
//两个发射按钮 位于屏幕两端
Row(
children: [
SizedBox(width: 48),
FireButton(
onTap: tankGame.onFireButtonTap,
),
Spacer(),
FireButton(
onTap: tankGame.onFireButtonTap,
),
SizedBox(width: 48),
],
),
//让发射按钮和摇杆保持一定间距
SizedBox(height: 20),
//两个摇杆 位于屏幕两端,发射按钮下方
Row(
children: [
SizedBox(width: 48),
JoyStick(
onChange: (Offset delta)=>tankGame.onLeftJoypadChange(delta),
),
Spacer(),
JoyStick(
onChange: (Offset delta)=>tankGame.onRightJoypadChange(delta),
),
SizedBox(width: 48)
],
),
SizedBox(height: 24)
],
),
],
)));
这样我们的基本布局就算完成了,先对布局结构有一个了解,具体内部什么样子,我们一步一步来。
在游戏开发前,我们需要先简单了解一下两个东西
component : 组件(我觉得它跟游戏开发中的 刚体 很像),如子弹、坦克等游戏角色都属于component
sprite : 这个内部方法很简单,主要是将图形绘制在游戏界面上
由component的定义可以知道,它与游戏的每帧都有关系,因此需要增加两个与game的update和render对应的方法,为了便于理解,我们依然为component的这两个方法命名为:update和render,同时抽出来:
abstract class BaseComponent{
void render(Canvas canvas);
void update(double t);
}
搞定! 下面再来布置一下我们的Game(TankGame)
TankGame继承自Game,我们从这里可以获得游戏场景大小,同时通过update和render驱动各个component,代码如下:
class TankGame extends Game{
//用来保存游戏场景尺寸
Size screenSize;
//游戏背景
BattleBackground bg;
@override
void render(Canvas canvas) {
//没有初始化成功的话,不进行绘制
if(screenSize == null)return;
//绘制背景
bg.render(canvas);
}
@override
void update(double t) {
if(screenSize == null)return;
}
///resize 这里的方法在屏幕尺寸变动和第一次初始化时会调用,
///我们可以在这里获取到屏幕的尺寸
@override
void resize(Size size) {
screenSize = size;
//初始化一个背景sprite
if(bg == null){
bg = BattleBackground(this);
}
}
}
我们在game里保存下场景尺寸,并且初始化一个bg,同时在render里调用bg的render方法,将背景绘制到游戏上,让我们看一下BattleBackground
背景(BattleBackground)实现非常简单,它的代码如下:
class BattleBackground with BaseComponent{
final TankGame game;
Sprite bgSprite;
Rect bgRect;
BattleBackground(this.game){
//将bgSprite初始化,并将地图图片引入进来
bgSprite = Sprite('new_map.webp');
//根据游戏场景尺寸确定一个rect,用来告诉sprite绘制区域
bgRect = Rect.fromLTWH(0, 0, game.screenSize.width, game.screenSize.height);
}
@override
void render(Canvas canvas) {
bgSprite.renderRect(canvas, bgRect);
}
@override
void update(double t) {
}
}
因为咱们的地图目前并没有什么变化,所以update方法可以不管,只需要render里绘制一下即可。
这里的大致流程是,game启动后,会循环调用下面的方法:
(TankGame)update->render->update->...
我们在game的render中调用背景的render方法,就可以绘制图片了。
至此,背景就添加成功了,下面我们制作摇杆
我们这里要用到widget,起名叫JoyStick。如果你会flutter开发,那么接下来的代码是非常简单的。
首先声明一个JoyStick
class JoyStick extends StatefulWidget{
//用于回传摇杆移动的方位
final void Function(Offset) onChange;
const JoyStick({Key key, this.onChange}) : super(key: key);
@override
State createState() {
return JoyStickState();
}
}
class JoyStickState extends State {}
state内部实现如下,代码比较多我将说明写在注释里
class JoyStickState extends State {
//摇杆中间的圆的位置,简称 摇杆头
Offset delta = Offset.zero;
//更新 摇杆头的位置,并将位置传出去(这样就可以控制坦克了)
void updateDelta(Offset newD){
widget.onChange(newD);
setState(() {
delta = newD;
});
}
//这个是根据用户移动摇杆头时的控制计算,主要是确保摇杆头的活动范围不能超出 外层白圈
void calculateDelta(Offset offset){
Offset newD = offset - Offset(bgSize/2,bgSize/2);
updateDelta(Offset.fromDirection(newD.direction,min(bgSize/4, newD.distance)));//活动范围控制在bgSize之内
}
//摇杆外层的白圈尺寸,摇杆头的尺寸跟这个也有关系
final double bgSize = 120;
@override
Widget build(BuildContext context) {
return SizedBox(
width: bgSize,height: bgSize,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(bgSize/2)
),
//监听用户手势
child: GestureDetector(
///摇杆底部白圈
child: Container(
decoration: BoxDecoration(
color: Color(0x88ffffff),
borderRadius: BorderRadius.circular(bgSize/2),
),
child: Center(
child: Transform.translate(offset: delta,
///摇杆头
child: SizedBox(
width: bgSize/2,height: bgSize/2,
child: Container(
decoration: BoxDecoration(
color: Color(0xccffffff),
borderRadius: BorderRadius.circular(30),
),
),
),),
),
),
onPanDown: onDragDown,
onPanUpdate: onDragUpdate,
onPanEnd: onDragEnd,
),
),
);
}
//三个方法主要用于获取用户触摸位置的数据
void onDragDown(DragDownDetails d) {
calculateDelta(d.localPosition);
}
void onDragUpdate(DragUpdateDetails d) {
calculateDelta(d.localPosition);
}
void onDragEnd(DragEndDetails d) {
updateDelta(Offset.zero);
}
}
这样摇杆部分就完了,回看runApp内的方法,这个时候运行一下就可以看到屏幕上面有个摇杆了。
就是俩白圈,我直接上代码了:
class FireButton extends StatelessWidget {
final void Function() onTap;
const FireButton({Key key, this.onTap}) : super(key: key);
@override
Widget build(BuildContext context) {
return SizedBox(
height: 64,width: 64,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(32)
),
child: GestureDetector(
child:Container(
decoration: BoxDecoration(
color: Color(0x88ffffff),
borderRadius: BorderRadius.circular(32),
),
),
onTap: onTap,
),
),
);
}
}
接下来我们开始绘制坦克
首先我们需要坦克的图片,并加载进flame.
别忘了在pub中添加,并get一下
之后回到main函数中的loadAssets()方法,加载刚才的图片资源:
void loadAssets(){
Flame.images.loadAll([
'new_map.webp',
'tank/t_body_blue.webp',
'tank/t_turret_blue.webp',
'tank/t_body_green.webp',
'tank/t_turret_green.webp',
'tank/t_body_sand.webp',
'tank/t_turret_sand.webp',
'tank/bullet_blue.webp',
'tank/bullet_green.webp',
'tank/bullet_sand.webp',
'explosion/explosion1.webp',
'explosion/explosion2.webp',
'explosion/explosion3.webp',
'explosion/explosion4.webp',
'explosion/explosion5.webp',
]);
}
ok,资源加载完毕,开始代码部分。
以玩家坦克为例我们先要继承一下baseComponent,同时我们需要分别控制身体和炮塔,所以需要分别进行绘制,即两个Sprite。
class Tank extends BaseComponent{
final TankGame game;
Sprite bodySprite,turretSprite;
//坦克出生位置
Offset position;
Tank(this.game,{this.position}){
//炮塔
turretSprite = Sprite('tank/t_turret_blue.webp');
//坦克身体
bodySprite= Sprite('tank/t_body_blue.webp');
}
//调整坦克整体大小的系数
final double ratio = 0.7;
@override
void render(Canvas canvas){
drawBody(Canvas canvas);
}
@override
void update(double t){}
}
我们在render方法中添加一个drawBody()方法,来绘制坦克 :
void drawBody(Canvas canvas){
//对画布操作前要先保存一下
canvas.save();
canvas.translate(position.dx, position.dy);
//绘制tank身体
bodySprite.renderRect(canvas,Rect.fromLTWH(-20*ratio, -15*ratio, 38*ratio, 32*ratio));
// 绘制炮塔
turretSprite.renderRect(canvas, Rect.fromLTWH(-1, -2*ratio, 22*ratio, 6*ratio));
canvas.restore();
}
坦克大小我是直接写的数值,而后面的ratio,是我用来调整大小用的。
现在我们的‘不会动’坦克就绘制完成了。
后面我们需要将摇杆和坦克联系起来已达到控制坦克的目的,不过碍于篇幅(我现在滑动页面都已经卡顿了)且控制坦克这三个方法需要详尽的说一下,因此我将挪到下一篇再讲,谢谢大家阅读。
再次感谢官方的文档及其贡献者,给我提供了很大的帮助,如果你很着急可以直接查阅官方文档
坦克大战