Flutter游戏:万有引力定律

网络配图

搭游戏主循环

要Flutter做一个游戏,我们需要先把一个简单的Flame游戏主循环脚手架给搭起来,这部分的内容在前面的《开始用Flutter做游戏吧》里面有详细的讲解哦!

新建一个hit-game.dart文件,用以下代码建立游戏主循环,这个游戏主循环是我们游戏的核心,我们待会再扩充里面的内容。

import 'dart:ui';
import 'package:flame/game.dart';

class HitGame extends Game {
  Size screenSize;

  void render(Canvas canvas) {}

  void update(double t) {}

  void resize(Size size) {}
}

然后修改main.dart文件,创建一个游戏类的实例,并调用runApp函数,而且因为runApp函数需要一个Widget对象,所以我们还要传递HitGame实例的widget属性。

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import 'package:flame/util.dart';

import 'package:hello_flame/hit-game.dart';

void main() async {
  Util flameUtil = Util();
  await flameUtil.fullScreen();
  await flameUtil.setOrientation(DeviceOrientation.portraitUp);

  HitGame game = HitGame();
  runApp(game.widget);
}

再回到hit-game.dart文件中,编辑游戏类,确定屏幕的大小尺寸,便于后面的绘图与对象移动操作。我们开始运行游戏时,Flutter会计算其暴露给应用程序的大小尺寸,但是呢,其他一些事件,例如翻转手机等操作,会导致Flutter重新计算其暴露给应用程序的大小尺寸。

现在有些新手机支持多种分辨率,让玩家可以在玩游戏时更改屏幕分辨率,我们需要确保每次Flutter通知我们说,应用程序的画布(Canvas)已经通过调整(resize)方法调整大小时,我们要重新计算。

在调整(resize)方法添加下面的代码。

  void resize(Size size) {
    screenSize = size;
  }

上面加的那一行代码只是将Flutter传递的新大小尺寸存储到screenSize实例变量,这样我们就可以在游戏主循环内访问它了。

绘制游戏背景

现在我们要为游戏绘制一个背景,为了简单这里就是一个纯色背景,这里可以使用任何一种颜色,但是有一点要注意,不能使用太亮眼的颜色,例如红色,因为那样会伤害玩家的眼睛。

在渲染(render)方法添加下面的代码。

  void render(Canvas canvas) {
    Rect bgRect = Rect.fromLTWH(0, 0, screenSize.width, screenSize.height);
    Paint bgPaint = Paint();
    bgPaint.color = Color(0xff576574);
    canvas.drawRect(bgRect, bgPaint);
  }

上面的代码中,第1行代码定义了一个矩形(Rect)类实例bgRect,坐标(0,0)即左上角,大小与屏幕大小一致。第2、3行代码定义了一个绘制(Paint)类实例bgPaint,随后为其分配颜色。第4行代码使用画布(Canvas)对象的画矩形(drawRect)方法绘制矩形。

兼容各种手机

现在市场上的Android设备奇奇怪怪的,各种型号加起来数千种,我们可以让自己写的游戏可以在所有设备上运行吗?

要适配不同设备,需要先了解“纵横比”的概念,纵横比可以是设备宽度和高度之间的比例,也可以是高度和宽度,这两者是可切换的,因为玩家可以旋转手机。

目前市场上常用的手机屏幕有几十种不同的纵横比,例如3:2、4:3、8:5、5:3、16:9、18.5:9等,我们以目前最常见的16:9为基础。

由于我们的游戏是纵向模式运行的,所以实际是以9:16为基础的,但是我们并不会把屏幕固定为9:16,而是把重点放在一个维度上作为基础,比如现在我们使用宽度为9,那么屏幕的尺寸基础为9:x的纵横比。

然后转换9:x的纵横比,把它变成9:13.5、9:12、9:14.4、9:15、9:16、9:18.5。这样我们就只需要处理纵向时手机的宽度,手机高度越大,游戏对象可以移动的空间就越多,反之亦然。

这样一来,无论玩家用什么尺寸和纵横比的手机玩我们的游戏,游戏对象总是具有相同的尺寸,轻轻松松就解决了。

接下来,我们在代码上实现这个方案,添加一个实例变量tileSize,这个实例变量将保持屏幕宽度的值除以9。同时我们把它放在调整(resize)方法里面,这样每次屏幕尺寸发生变化时,都能得到最新的大小。

class HitGame extends Game {
  Size screenSize;
  double tileSize;

  ...

  void resize(Size size) {
    screenSize = size;
    tileSize = screenSize.width / 9;
  }
}

组件与小精灵

在游戏中,组件、对象、游戏对象这三个名字描述的是同一个概念,通常指在游戏中执行某​​些操作的对象,例如主角、敌人、陆地地形、地图、菜单、子弹等等,下面我们用“组件”来指它。

精灵(sprite)也是游戏中的一个重要概念,通常指游戏中的元素、主角,NPC之类的图片、动画、按键等。组件通常是和精灵耦合的,例如在敌人组件的位置上绘制某个精灵,以便让玩家知道敌人在哪里。

但是并非所有的组件都用于定位或绘制,有些组件没有位置,也没有在屏幕上绘制精灵。它们只是用于不同的功能,这些组件称为控制器(controllers),控制器控制游戏的行为,同时不在屏幕上显示。

举个例子,有一个敌人产生者控制器,这个控制器就在等待产生敌人组件的时间,当那个时间到来时,控制器会创建一个敌人对象,并将其提交给游戏主循环,然后游戏主循环获取到新的敌人对象并相应地更新和渲染它。

我们可以将组件视为迷你游戏循环或游戏主循环的子部分,它们有自己的更新(update)和渲染(render)方法,活用组件可以提高我们代码的可维护性。

创建游戏组件

现在我们需要一个存放组件的地方,在lib下创建一个components文件夹,在该文件夹中,再创建一个名为fly.dart的新文件。

import 'dart:ui';

class Fly {
  void render(Canvas c) {}

  void update(double t) {}
}

上面代码中,导入dart:ui库,这样我们就可以使用画布(Canvas)类,就像主游戏类文件hit-game.dart一样。然后用两个方法声明一个Fly类:更新(update)和渲染(render)。

是不是发现非常像游戏主循环,因为当这个组件轮到更新(update)和渲染(render)时,游戏主循环会调用这些方法。

Fly组件应该要保存它的位置和大小,所以我们要创建相关的实例变量。我们可以选择创建double xdouble ydouble widthdouble height,但是这种方式要4个变量。

更好的方式是,对于具有x/ywidth/height值的数据类型,我们有很多选择,Dart中的大小(Size)和偏移(Offset)类,Dart的math库的点(Point)类和Flame的位置(Position)类,但是这样我们还是需要两个变量,一个用于x/y对、一个用于width/height对。

还有更好的方式是,在之前绘制背景时,我们用到了矩形(Rect)类,通过fromLTWH构造函数构造实例时,要定义它的x轴、y轴、宽度、高度。

这种方式也有缺点,就是Rect实例是不可变的,这就意味着我们无法通过直接设置值来更改其任何属性,但是有解决方案,我们可以使用矩形(Rect)实例的移动(shift)和翻转(translate)方法来移动矩形。

现在我们用代码实现这个方案,添加一个实例变量flyRect,然后引用游戏类,以便我们可以访问像screenSize这样的属性。最后再添加另一个实例变量game,它将作为父游戏类的链接和引用。

import 'dart:ui';

import 'package:hello_flame/hit-game.dart';

class Fly {
  final HitGame game;
  Rect flyRect;

上面的实例变量game是最终变量,因为我们的组件在其整个生命周期中只存在于一个游戏类中,因此我们不需要父游戏是动态的。

然后,我们还需要初始化这些实例变量的值,我们需要为这个Fly类编写一个构造函数,构造函数会在创建类的实例时运行,而且只运行一次,因此通常用于初始化。

  Fly(this.game, double x, double y) {
    flyRect = Rect.fromLTWH(x, y, game.tileSize, game.tileSize);
  }

上面的代码中,默认构造函数接受三个参数,第1个参数this.game指定传递给game属性的任何值,第2、3个参数xy将是新构造实例的初始位置。

然后在默认构造函数里面的代码中,我们为flyRect分配了一个新的矩形,使用传递的xy参数设置坐标位置,使用game.tileSize分配宽度和高度。

到这里为止,我们的fly.dart里面应该有以下代码。

import 'dart:ui';

import 'package:hello_flame/hit-game.dart';

class Fly {
  final HitGame game;
  Rect flyRect;

  Fly(this.game, double x, double y) {
    flyRect = Rect.fromLTWH(x, y, game.tileSize, game.tileSize);
  }

  void render(Canvas c) {}

  void update(double t) {}
}

绘制游戏组件

上面我们已经拥有了一个矩形(Rect),但是这还不能绘制一个矩形,我们还需要一个绘制(Paint)对象。而且为了避免在渲染(render)方法中重新初始化绘制(Paint)对象,我们最好把它存储在实例变量中。

现在我们还要在构造函数中初始化flyPaint,添加以下代码。

class Fly {
  final HitGame game;
  Rect flyRect;
  Paint flyPaint;

  Fly(this.game, double x, double y) {
    flyRect = Rect.fromLTWH(x, y, game.tileSize, game.tileSize);
    flyPaint = Paint();
    flyPaint.color = Color(0xff6ab04c);
  }

  void render(Canvas c) {
    c.drawRect(flyRect, flyPaint);
  }

解决一些问题

在开始生产一个游戏组件之前,先分析一下有哪些要解决技术问题,当我们的游戏运行时,它并不知道屏幕有多大,游戏默认认为它在0x0的屏幕上运行,所以我们要依靠调整大小的方法让游戏知道屏幕有多大。

hit-game.dart中,当渲染(render)方法运行时,实例变量screenSize已经设置好了,因为游戏运行时会按照下面的顺序调用方法。

  1. 通过构造函数创建一个类的实例(这个例子里没有构造函数,所以跳过哈)。
  2. Flutter调用调整(resize)方法并设置实例变量screenSize
  3. 游戏主循环开始。
  4. 游戏主循环:调用更新(update)方法。
  5. 游戏主循环:调用渲染(render)方法。
  6. 游戏主循环结束,回到第3步,开始新的循环。

在理想情况下,初始化代码是我们准备和创建对象的地方,它应该只运行一次,我们可以使用调整(resize)方法来启动初始化代码,这看起来很正常。

但是勒,如果手机改变了分辨率或纵向旋转到横向时,Flutter会再次调用调整(resize)方法,如果我们将初始化代码放在调整(resize)方法中,它会多次再次。同样的,初始化代码应该只运行一次,假如屏幕上已经有一个NPC了,玩家将手机翻转180度,触发调整大小的方法,然后再次运行初始化代码,屏幕上又出现了另一个NPC,真的让人头大。

我们可以解决这个问题,依然使用调整(resize)作为初始化代码的启动器,但是我们可以额外声明一个布尔实例变量,可以取类似“isInitialized”的名字,然后默认值为“false”,在调整(resize)方法中,可以先检查“isInitialized”是否为“false”,如果是,就运行初始化代码并将值设置为“true”。

上面的方法只能说是一个不完美的方案,因为它引入了一个不必要的实例变量,Flame为我们提供了更好的解决方式。

现在打开hit-game.dart文件,在HitGame类中编写两个方法:构造函数和名为initialize的方法,构造函数里只包含一行代码:调用initialize方法。

我们将使用异步函数来等待屏幕大小,所以要使用到asyncinitialize关键字来实现异步方法。这同时也是为什么初始化代码不能直接放在构造函数中,并且必须放在单独的方法上的原因,在Dart语法中,构造函数是不能是异步的。

class HitGame extends Game {
  Size screenSize;
  double tileSize;

  HitGame() {
    initialize();
  }

  void initialize() async {}

接下来还需要调用Flame库的util.initialDimensions函数,导入Flame库并在文件hit-game.dart文件里添加下面代码。

import 'dart:ui';
import 'package:flame/game.dart';

import 'package:flame/flame.dart';

class HitGame extends Game {
  Size screenSize;
  double tileSize;

  HitGame() {
    initialize();
  }

  void initialize() async {
    resize(await Flame.util.initialDimensions());
  }

  ...

  void resize(Size size) {
    screenSize = size;
    tileSize = screenSize.width / 9;
  }
}

上面的代码中,调整(resize)方法接受一个Size类型的参数,Flame的util.initialDimensions函数返回一个Future<Size>,所以我们等待(await)未来(Future)完成,这样可以得到一个Size

一旦我们有一个大小(Size)值,就可以将其插入以调整(resize)大小。我们现在可以直接将值插入screenSize,但是也需要重新计算tileSize,另外后面我们还会计算其他东西,所以还是保存在调整(resize)方法里,我们只需要调用它来重新计算所有内容。

生产准备工作

到这一步,我们已经为生产一个游戏组件做好了准备工作,为了让游戏类可以访问和创建Fly类的实例,必须先在文件顶部导入它,并新建一个名为敌人(enemy)的List类型的实例变量。

...

import 'package:hello_flame/components/fly.dart';

class HitGame extends Game {
  Size screenSize;
  double tileSize;
  List<Fly> enemy;

现在实例变量敌人(enemy)是个null值,所以要在initialize方法中为它分配一个实际的列表。

  void initialize() async {
    enemy = List<Fly>();
    resize(await Flame.util.initialDimensions());
  }

即使现在还没有敌人(enemy),我们也要使用ListforEach方法循环遍历敌人(enemy)实例变量,并在更新(update)和渲染(render)方法上调用相应的方法。

因为我们调用对象的顺序直接影响游戏在屏幕上的显示方式,敌人本身的顺序无关紧要,重要的是在敌人后面绘制背景,所以现在添加下面代码到更新(update)和渲染(render)方法上。

  void render(Canvas canvas) {
    Rect bgRect = Rect.fromLTWH(0, 0, screenSize.width, screenSize.height);
    Paint bgPaint = Paint();
    bgPaint.color = Color(0xff576574);
    canvas.drawRect(bgRect, bgPaint);

    enemy.forEach((Fly fly) => fly.render(canvas));
  }

上面代码中,forEach方法使用一个函数作为参数,然后它为List中的每个项目都调用一次该函数,将当前迭代中的项目作为参数传递。

生产游戏组件

我们的游戏最终会定期生产游戏组件,所以需要一个可以重复使用的生产方法,创建一个produceFly方法,并在initialize方法中调用这个方法。这样的话,确定屏幕尺寸后,就会开始生产游戏组件。

  void initialize() async {
    enemy = List<Fly>();
    resize(await Flame.util.initialDimensions());

    produceFly();
  }

  void produceFly() {
    enemy.add(Fly(this, 50, 50));
  }

在上面代码的produceFly方法中,创建了一个Fly类的新实例,Fly类的默认构造函数需要传递3个参数,HitGame的实例、x初始位置、y初始位置。对于第1个HitGame实例,我们通过this直接使用当前正在操作的实例,对于第2、3个参数,我们暂时先通过硬编码传入(50,50)。

接下来,我们要随机化游戏组件的初始位置,为此需要导入Dart的数学(dart:math)库,并创建另一个类型为随机(Random)的实例变量rnd,这样会使该变量可以重用,每次我们需要随机的时候都不用再创建一个新的随机(Random)实例了。

当然,也不要忘了在initialize方法中初始化这个实例变量。

...

import 'dart:math';

class HitGame extends Game {
  Size screenSize;
  double tileSize;
  List<Fly> enemy;
  Random rnd;

  ...

  void initialize() async {
    enemy = List<Fly>();
    rnd = Random();
    resize(await Flame.util.initialDimensions());

    produceFly();
  }

现在,我们开始编辑produceFly方法,使xy位置随机化,Random类有一个nextDouble方法,其返回一个在0(包括)和1(不包括)之间的任何double值。

接下来我们调用这个方法,并将它乘以屏幕的宽度,再减去游戏组件的宽度,因为游戏组件位于其左上角,并将其分配给初始值x。然后再对初始值y做同样的操作,但是使用屏幕的高度减去游戏组件的高度。

现在我们的游戏组件是一个正方形,所以它的宽度和高度是相同的,而且宽高都是tileSize,因此,为了获得最大值,我们需要将tileSize减去屏幕的宽度或高度。

然后,当我们创建Fly类的新实例时,将这些xy变量作为初始位置。

  void produceFly() {
    double x = rnd.nextDouble() * (screenSize.width - tileSize);
    double y = rnd.nextDouble() * (screenSize.height - tileSize);
    enemy.add(Fly(this, x, y));
  }

让组件可点击

要开始让游戏组件落下,我们的游戏就需要接受来自玩家的输入。然后我们明确一下玩家点击游戏组件时会发生什么,游戏组件的颜色应该变成红色,并下落到屏幕的底部。再然后,当游戏组件离开玩家视角的时候,我们要销毁该游戏组件的实例,让玩家的设备不会浪费CPU资源来更新它。

首先导入Flutter的手势(flutter/gestures.dart)库,在游戏类中添加一个处理函数,该函数将负责处理点击按下(onTapDown)事件,同时接受TapDownDetails类型的参数。

...

import 'package:flutter/gestures.dart';

class HitGame extends Game {
  ...

  void onTapDown(TapDownDetails d) {}
}

然后再回到main.dart文件中,导入Flutter的手势(flutter/gestures.dart)库,并创建一个手势识别器,将onTapDown属性链接到游戏类的onTapDown方法,最后还要使用Flame实用程序的添加手势识别器(addGestureRecognizer)方法注册识别器。

...

import 'package:flutter/gestures.dart';

void main() async {
  ...

  TapGestureRecognizer tapper = TapGestureRecognizer();
  tapper.onTapDown = game.onTapDown;
  flameUtil.addGestureRecognizer(tapper);
}

再然后呢,打开fly.dart文件,添加一个事件处理程序,只有点击此Fly实例时才会触发该事件处理程序。我们不用知道Fly实例的位置,反正如果我们如果调用此处理程序,就会调用此Fly实例。

class Fly {
  ...

  void onTapDown() {}
}

现在打开hit-game.dart文件,再分接处理程序内部,为此我们需要循环遍历所有现有的游戏组件,并检查分接位置是否在游戏组件的边界矩形内。

矩形(Rect)类有一个包含(contains)方法,此方法接受偏移(Offset)作为参数,如果偏移(Offset)传递的是在调用它的矩形(Rect)的边界内,则返回true,否则返回false

通过处理程序传递的TapDownDetails实例,我们可以获得globalPosition属性的值,这个属性是点击按下时偏移(Offset)量。这样我们就可以将globalPosition属性传递给Fly实例的包含(contains)方法,就知道点击是否有效。

  void onTapDown(TapDownDetails d) {
    enemy.forEach((Fly fly) {
      if (fly.flyRect.contains(d.globalPosition)) {
        fly.onTapDown();
      }
    });
  }

在上面的代码中,循环遍历了enemy内的所有Fly实例,与渲染(render)和更新(update)中使用的逻辑相似,我们传入一个函数,该函数针对当前enemy中的每个Fly实例运行。

Fly类有一个名为flyRect的实例变量,它是一个矩形(Rect),因此它也有一个包含(contains)方法,我们使用包含(contains)方法检查传递的TapDownDetails实例的globalPosition是否在矩形内。

如果它在里面,就可以确定在当前的forEach迭代中Fly的实例被点击了,我们就可以通过调用它的onTapDown方法来通知它已经被点击。

自由落体运动

现在我们开始处理敌人(enemy)的点击,打开fly.dart文件。第一个要改变的是颜色,渲染敌人(enemy)时,我们使用flyPaint的绘制(Paint)对象,它有一个颜色(color)属性,如果敌人(enemy)没有被点击,它会被分配绿色,如果我们改变它,它应该会反映在下一次调用渲染时,Flutter每秒60帧,也就是每秒渲染60次,从人类的角度来看,就是一瞬间的事情。

  void onTapDown() {
    flyPaint.color = Color(0xffff4757);
  }

上面的代码把敌人(enemy)的颜色更改为红色。现在运行游戏,我们可以看到,单玩家点击飞行敌人时,敌人从绿色变成红色了。

当敌人被点击时,它会因重力而下落,不会停留在空中。要实现这一点,我们就要开始用到之前一直忽略的游戏主循环的更新(update)方法了。

更新(update)方法通常用于更改游戏中未被玩家输入触发的任何内容的代码,此时fly.dart中的更新(update)方法已经被游戏主循环的更新(update)方法调用。

使敌人动画看起来像是在下降,就是一个需要更新(update)的逻辑,但是也不能把动画放在那里,因为只有它被点击了才会掉落,所以我们需要定义一个保存此信息的实例变量isDead

class Fly {
  final HitGame game;
  Rect flyRect;
  Paint flyPaint;
  bool isDead = false;

然后现在需要编辑Fly类的更新(update)方法,当判断游戏组件已经被点击,就更改其边界矩形,向其顶部属性添加一定值以使其向下移动。然后在onTapDown处理程序中,将值设置为true,并在更新(update)方法中添加落下动画的代码。

  void update(double t) {
    if (isDead) {
      flyRect = flyRect.translate(0, game.tileSize * 12 * t);
    }
  }

  void onTapDown() {
    isDead = true;
    flyPaint.color = Color(0xffff4757);
  }

上面的代码中,每次调用更新(update)时,Fly实例会检查其isDead属性的值是否为true,如果为true就调用其翻转(translate)方法,从现有的边界矩形构建一个新的矩形(Rect),然后再将这个新创建的矩形(Rect)实例分配回去给flyRect

对于翻转(translate)方法的参数,x部分保留为0,因为我们不想让游戏组件向左或向右移动。y部分呢,出现了一个double类型的变量t,该变量的全名应该是时间增量(timeDelta),但是脚手架把它命名为t

当我们说游戏当帧频率为每秒60帧时,就相当用1000毫秒除于60秒等于16.666666666毫秒,即每帧占用16.666666666毫秒的时间跨度,我们可以基于这个t值进行计算。

玩家的手机上不仅是运行游戏,还会运行大量应用,后台还会运行其他程序,这些应用程序可能正在做一些可能在每个周期中占用更多或更少时间的事情。而CPU会尝试给予所有正在运行的进程相同的资源和时间,但是有些进程可以做到花费更少的资源和时间。

这就是时间增量(t)有用的地方,它包含自上次运行更新(update)以来经过的时间量,该值以秒为单位。使用时间增量(t),我们可以计算应该发生的移动量。

假如,游戏由于某种原因以每秒1帧的恒定速度完美运行,因此时间差值恰好为1。如果我们打算以每秒10个图块的速度移动一个对象,那么我们要加/减去10、乘以图块大小的值、再乘以时间增量值1到我们希望对象移动的维度。这将会出现每秒10个图块的移动。

现在再假如一下,游戏以每秒4帧的恒定速度完美运行,时间差值始终为0.25,使用每秒10个图块的速度移动。每帧移动对象10个图块大小乘以图块大小、乘以0.25等于2.5乘以图块大小。也就是说每秒4帧的运动仍然是每秒10个图块大小。

所以,使用公式game.tileSize * 12 * t应用该逻辑,无论时间增量(t)值是什么时间值,我们都可以得到12次game.tileSize每秒运动值的恒定运动。

上面公式中的12是一个看起来不错的值,实际可以根据需要修改它,使移动的速度更慢或更快。

让游戏有趣些

只有一个游戏组件的话,点一下就没了,为了更好玩一些,我们需要在游戏组件被点击后在屏幕上产生更多的游戏组件,在onTapDown中添加下面代码。

  void onTapDown() {
    isDead = true;
    flyPaint.color = Color(0xffff4757);
    game.produceFly();
  }

现在当一个游戏组件被点击后,会产生更多的游戏组件了,但是还有问题。当一个游戏组件落下时,它会一直下降,也就是其yY坐标一直在增加值,直到玩家终止游戏为止。这样的话,生产的游戏组件越多,轻则游戏卡顿,重则出现数据溢出错误。

要解决这个问题,就需要添加一些代码来删除所有已经脱离屏幕的游戏组件,首先添加一个实例变量isOffScreen,然后在更新(update)方法中,添加如下代码。

class Fly {
  ...
  bool isOffScreen = false;

  ...

  void update(double t) {
    if (isDead) {
      flyRect = flyRect.translate(0, game.tileSize * 12 * t);
      if (flyRect.top > game.screenSize.height) {
        isOffScreen = true;
      }
    }
  }

上面代码中,我们检查此游戏组件实例的矩形顶部是否大于屏幕高度,如果是就将isOffScreen设置成true。因为屏幕的平面原点为(0,0)即左上角,所以屏幕的底部的y值等于屏幕高度。

接下来我们还要销毁所有isOffScreen属性为trueFly实例,使用ListremoveWhere方法可以删除所有符合条件的项目,它和forEach类似,但是它需要一个返回布尔值(bool)的方法。而正好的是,isOffScreen就是一个布尔值(bool),所以我们直接返回它就好了。

回到hit-game.dart文件中,添加下面的代码。

  void update(double t) {
    enemy.forEach((Fly fly) => fly.update(t));
    enemy.removeWhere((Fly fly) => fly.isOffScreen);
  }

上面代码中,创建了一个匿名函数,并将Fly作为参数,然后立即返回它作为参数获得的Fly实例的isOffScreen属性。然后将此匿名函数作为参数传递给enemy列表的removeWhere方法,该方法为列表中的每个Fly实例运行传递的方法,如果返回true则删除实例。

现在运行游戏,可以看到下面图片所展示的效果。

万有引力定律GIF图地址

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350