发布于 2026-01-06 12 阅读
0

Flutter游戏教程:水果切片 目标 学习内容 实现方法 总结

Flutter游戏教程:水果切片

目标

你将学到什么

实施

最后想说的

目标

本教程的目标是以基础的方式开发一款类似《水果忍者》的游戏。我们将不使用任何框架,以便读者可以从零开始学习其工作原理。

Flutte 水果忍者克隆动画

你将学到什么

完成本教程后,您将能够

  • 使用手势检测器
  • 在屏幕上作画
  • 实现基本碰撞检查
  • 实现一个基本的重力模拟

实施

我们游戏的基本版本包含以下几个需要解决的问题:

  • 实现一个“切片器”,它可以沿着我们用手指滑动创建的路径进行切片。
  • 实现水果的外观
  • 利用重力使水果向下流动
  • 检查切片机与水果是否发生碰撞

切片机

我们先来看拖动屏幕时应该出现的切片器:

class SlicePainter extends CustomPainter {
  SlicePainter({this.pointsList});

  List<Offset> pointsList;
  final Paint paintObject = Paint();

  @override
  void paint(Canvas canvas, Size size) {
    _drawPath(canvas);
  }

  void _drawPath(Canvas canvas) {
    Path path = Path();

    paintObject.color = Colors.white;
    paintObject.strokeWidth = 3;
    paintObject.style = PaintingStyle.fill;

    if (pointsList.length < 2) {
      return;
    }

    paintObject.style = PaintingStyle.stroke;

    path.moveTo(pointsList[0].dx, pointsList[0].dy);

    for (int i = 1; i < pointsList.length - 1; i++) {
      if (pointsList[i] == null) {
        continue;
      }

      path.lineTo(pointsList[i].dx, pointsList[i].dy);
    }

    canvas.drawPath(path, paintObject);
  }

  @override
  bool shouldRepaint(SlicePainter oldDelegate) => true;
}
Enter fullscreen mode Exit fullscreen mode

这个SlicePainter函数需要接收若干个点,并在屏幕上绘制这些点,并在点之间绘制连接线。为此,我们创建一个点集Path,将起始点移动到点集第一个元素的坐标,然后遍历每个元素(从第二个元素开始),并从前一个点到当前点绘制一条线。

CustomPainter如果一个对象没有被使用,它本身就没有价值。这就是为什么我们需要一个画布来识别手指滑动,捕捉这些点,并将它们放入我们新创建的对象的构造函数中CustomPainter,以便实际绘制路径。

List<Widget> _getStack() {
  List<Widget> widgetsOnStack = List();

  widgetsOnStack.add(_getSlice());
  widgetsOnStack.add(_getGestureDetector());

  return widgetsOnStack;
}
Enter fullscreen mode Exit fullscreen mode

我们的组件由一个 组成Stack。底部将是我们滑动操作生成的切片,在其上方我们希望放置 ,GestureDetector因为我们不希望任何东西阻挡检测。

class TouchSlice {
   TouchSlice({
     this.pointsList
   });
   List<Offset> pointsList;
}
Enter fullscreen mode Exit fullscreen mode

首先,我们创建一个模型类来表示切片。我们将其命名为TouchSlice,并使其接受一个Offset切片列表作为唯一参数。

Widget _getSlice() {
  if (touchSlice == null) {
    return Container();
  }

  return CustomPaint(
    size: Size.infinite,
    painter: SlicePainter(
      pointsList: touchSlice.pointsList,
    )
  );
}
Enter fullscreen mode Exit fullscreen mode

然后我们实现该_getSlice()方法,该方法返回一个对象,该对象根据控件实例CustomPaint的点列表绘制我们之前创建的切片。该对象始终为空。让我们通过添加一个.TouchSliceCanvasAreaTouchSliceGestureDetector

检测滑动手势

Widget _getGestureDetector() {
  return GestureDetector(
    onScaleStart: (details) {
      setState(() {
        _setNewSlice(details);
      });
    },
    onScaleUpdate: (details) {
      setState(() {
        _addPointToSlice(details);
      });
    },
    onScaleEnd: (details) {
      setState(() {
        touchSlice = null;
      });
    }
  );
}
Enter fullscreen mode Exit fullscreen mode

手势检测器监听三个事件:

  • onScaleStart这是我们开始滑动时触发的事件。这应该会向状态中添加一个新的 TouchSlice,该 TouchSlice 具有一个点。
  • onScaleUpdat当我们在屏幕上移动手指时,就会调用此函数。这应该会在我们的现有点列表中添加一个新点。TouchSlice
  • onScaleEnd当我们松开手指离开屏幕时,会调用此方法。这应该将值设置TouchSlice为 null,以便让切片消失。

让我们来实现这些方法吧!

void _setNewSlice(details) {
  touchSlice = TouchSlice(pointsList: [details.localFocalPoint]);
}

void _addPointToSlice(ScaleUpdateDetails details) {
  touchSlice.pointsList.add(details.localFocalPoint);
}

void _resetSlice() {
  touchSlice = null;
}
Enter fullscreen mode Exit fullscreen mode

测试时间到!

让我们通过构建和启动应用程序来看看实际效果如何。

Flutter 在屏幕上绘制

哦!我们忘了限制可以绘制的线段的长度。让我们通过将线上的点数限制为 16 个来纠正它。

void _addPointToSlice(ScaleUpdateDetails details) {
  if (touchSlice.pointsList.length > 16) {
    touchSlice.pointsList.removeAt(0);
  }
  touchSlice.pointsList.add(details.localFocalPoint);
}
Enter fullscreen mode Exit fullscreen mode

好的,如果点数超过16个,我们先去掉第一个点,再加上最后一个点。这样我们就画出了一条蛇。

彩色背景

黑底白线看起来很单调。我们不妨换个彩色背景,让它看起来更吸引人。

  List<Widget> _getStack() {
    List<Widget> widgetsOnStack = List();

    widgetsOnStack.add(_getBackground());
    widgetsOnStack.add(_getSlice());
    widgetsOnStack.add(_getGestureDetector());

    return widgetsOnStack;
  }

Container _getBackground() {
  return Container(
    decoration: new BoxDecoration(
      gradient: new RadialGradient(
        stops: [0.2, 1.0],
        colors: [
          Color(0xffFEB692),
          Color(0xffEA5455)
        ],
      )
    ),
  );
}
Enter fullscreen mode Exit fullscreen mode

径向渐变应该能让整体效果不那么阴郁。

Flutter 在屏幕上绘制径向渐变

加入水果

好了,接下来就是最有趣的部分了!我们要在游戏中加入水果。

class Fruit {
  Fruit({
    this.position,
    this.width,
    this.height
  });

  Offset position;
  double width;
  double height;

  bool isPointInside(Offset point) {
    if (point.dx < position.dx) {
      return false;
    }

    if (point.dx > position.dx + width) {
      return false;
    }

    if (point.dy < position.dy) {
      return false;
    }

    if (point.dy > position.dy + height) {
      return false;
    }

    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

我们的水果应该保持其位置,以便我们能在屏幕上绘制它,并在之后调整它的位置。它还应该有边界感知,因为我们需要检查切片是否碰到了它的边界。为了帮助我们确定这一点,我们创建了一个名为 `isboundary` 的公共方法,isPointInside该方法返回给定点是否在水果边界内。

List<Fruit> fruits = List();
...
widgetsOnStack.addAll(_getFruits());
...
List<Widget> _getFruits() {
  List<Widget> list = new List();

  for (Fruit fruit in fruits) {
    list.add(
      Positioned(
        top: fruit.position.dy,
        left: fruit.position.dx,
        child: Container(
          width: fruit.width,
          height: fruit.height,
          color: Colors.white
        )
      )
    );
  }

  return list;
}
Enter fullscreen mode Exit fullscreen mode

为了存储屏幕上当前所有水果的数据,我们给组件状态添加了一个名为 fruits 的新成员变量,它是一个包含我们刚刚创建的 Fruit 类元素的列表。我们使用一个Positioned组件来定位列表中的水果。我们也可以像使用 Slice 组件那样使用另一个CustomPaint组件,但为了简单起见,我们选择使用组件树的方法。
作为第一次迭代,我们显示一个白色方块而不是实际的水果,因为这一步的目的是显示内容并检查碰撞。美化处理可以在之后进行。

为了使碰撞检测正常工作,我们需要在每次向我们的数据中添加一个点时检查碰撞Slice

...
onScaleUpdate: (details) {
  setState(() {
    _addPointToSlice(details);
    _checkCollision();
  });
},
...
_checkCollision() {
  if (touchSlice == null) {
    return;
  }

  for (Fruit fruit in List.from(fruits)) {
    for (Offset point in touchSlice.pointsList) {
      if (!fruit.isPointInside(point)) {
        continue;
      }

      fruits.remove(fruit);
      break;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

我们遍历一个从水果列表派生而来的新列表。对于每个水果,我们检查每个点是否在其内部。如果在其内部,则将该水果从内层循环中移除,Stack因为break如果发生碰撞,则无需检查其余点。

现在我们有了一个水果列表和一个显示它们的方法,但是列表中还没有水果。让我们通过Fruit在列表中添加一个水果来改变这种情况initState

@override
void initState() {
  fruits.add(new Fruit(
    position: Offset(100, 100),
    width: 80,
    height: 80
  ));
  super.initState();
}
Enter fullscreen mode Exit fullscreen mode

Flutter Fruit Ninja 碰撞检测简单

太好了,我们可以在屏幕上画一条线,然后让一个矩形消失。不过有一点让我不太满意,就是我们一碰到它,它就立刻消失了。我们想要的是切开它的效果。所以,我们_checkCollision稍微修改一下算法。

_checkCollision() {
  if (touchSlice == null) {
    return;
  }

  for (Fruit fruit in List.from(fruits)) {
    bool firstPointOutside = false;
    bool secondPointInside = false;

    for (Offset point in touchSlice.pointsList) {
      if (!firstPointOutside && !fruit.isPointInside(point)) {
        firstPointOutside = true;
        continue;
      }

      if (firstPointOutside && fruit.isPointInside(point)) {
        secondPointInside = true;
        continue;
      }

      if (secondPointInside && !fruit.isPointInside(point)) {
        fruits.remove(fruit);
        break;
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

现在,只有当线上的一个点位于果实外部,第二个点位于果实内部,第三个点位于果实外部时,算法才会将移动判定为碰撞。这样可以确保发生类似切割之类的动作。

Flutter Fruit Ninja 高级碰撞

一个白色的长方形水果看起来不太诱人,而且也让人没有想切开的欲望。让我们换个更吸引人的图片来改变这一点吧。

甜瓜矢量图概览

我在设计和艺术方面没什么天赋。我尝试创作一些简单的矢量图形,来表现甜瓜的各个部分:一个完整​​的甜瓜、甜瓜的左右两半,以及一滴水花。

我们要注意,当西瓜出现时,要看到整个西瓜;切开后,要看到两部分。

List<Widget> _getFruits() {
  List<Widget> list = new List();

  for (Fruit fruit in fruits) {
    list.add(
      Positioned(
        top: fruit.position.dy,
        left: fruit.position.dx,
        child: _getMelon(fruit)
      )
    );
  }

  return list;
}

Widget _getMelon(Fruit fruit) {
  return Image.asset(
      'assets/melon_uncut.png',
      height: 80,
      fit: BoxFit.fitHeight
  );
}
Enter fullscreen mode Exit fullscreen mode

我们先从简单的部分开始:替换白色矩形。Container我们不返回一个 `<a>`,而是返回一个 `<a>` 的返回值,getMelon()该返回值接受一个 `Fruit` 对象并返回一个 `<a>`Image对象,具体来说,就是我们为其创建了资源的那个 `<a>` 对象。

好了,现在我们希望把甜瓜切开后变成两个。

class _CanvasAreaState<CanvasArea> extends State {
  List<FruitPart> fruitParts = List();
  ...
  List<Widget> _getStack() {
    List<Widget> widgetsOnStack = List();

    widgetsOnStack.add(_getBackground());
    widgetsOnStack.add(_getSlice());
    widgetsOnStack.addAll(_getFruitParts());
    widgetsOnStack.addAll(_getFruits());
    widgetsOnStack.add(_getGestureDetector());

    return widgetsOnStack;
  }

  List<Widget> _getFruitParts() {
    List<Widget> list = new List();

    for (FruitPart fruitPart in fruitParts) {
      list.add(
        Positioned(
          top: fruitPart.position.dy,
          left: fruitPart.position.dx,
          child: _getMelonCut(fruitPart)
        )
      );
    }

    return list;
  }

  Widget _getMelonCut(FruitPart fruitPart) {
    return Image.asset(
      fruitPart.isLeft ? 'assets/melon_cut.png': 'assets/melon_cut_right.png',
      height: 80,
      fit: BoxFit.fitHeight
    );
  }

  _checkCollision() {
    ...
    for (Fruit fruit in List.from(fruits)) {
    ...
        if (secondPointInside && !fruit.isPointInside(point)) {
          fruits.remove(fruit);
          _turnFruitIntoParts(fruit);
          break;
        }
      }
    }
  }

  void _turnFruitIntoParts(Fruit hit) {
    FruitPart leftFruitPart = FruitPart(
        position: Offset(
          hit.position.dx - hit.width / 8,
          hit.position.dy
        ),
        width: hit.width / 2,
        height: hit.height,
        isLeft: true
    );

    FruitPart rightFruitPart = FruitPart(
        position: Offset(
          hit.position.dx + hit.width / 4 + hit.width / 8,
          hit.position.dy
        ),
        width: hit.width / 2,
        height: hit.height,
        isLeft: false
    );

    setState(() {
      fruitParts.add(leftFruitPart);
      fruitParts.add(rightFruitPart);
      fruits.remove(hit);
    });
  }
}

class FruitPart {
  FruitPart({
    this.position,
    this.width,
    this.height,
    this.isLeft
  });

  Offset position;
  double width;
  double height;
  bool isLeft;
}

Enter fullscreen mode Exit fullscreen mode

我们引入了一个名为 `Fruits` 的新类FruitPart,它代表水果的左右两部分。该类的属性与之前的类略有不同Fruit。`position`、`width` 和 `height` 属性仍然保留,但新增了一个名为 `isLeft` 的布尔变量,用于判断当前是左侧还是右侧的水果部分。此外,也不再需要检查某个点是否在内部的方法。
然后,我们在状态中添加了一个新的成员变量:fruitParts`Fruits`,它代表当前屏幕上的水果部分列表。这些列表会被添加到 `Fruits` 类下方的堆栈中。`isLeft`isLeft属性决定加载左侧切面还是右侧切面的图像资源。
当切片与水果发生碰撞时,除了移除水果之外,我们还会放置两个水果部分。

蝴蝶果切成两半

下水果雨了

现在我们希望水果的行为像《水果忍者》中那样:在某个点生成,它们被“扔”到某个方向,并不断被模拟的重力拉下来。

class Fruit extends GravitationalObject {
  Fruit({
    position,
    this.width,
    this.height,
    gravitySpeed = 0.0,
    additionalForce = const Offset(0,0)
  }) : super(position: position, gravitySpeed: gravitySpeed, additionalForce: additionalForce);

  double width;
  double height;
  ...
}

class FruitPart extends GravitationalObject {
  FruitPart({
    position,
    this.width,
    this.height,
    this.isLeft,
    gravitySpeed = 0.0,
    additionalForce = const Offset(0,0)
  }) : super(position: position, gravitySpeed: gravitySpeed, additionalForce: additionalForce);

  double width;
  double height;
  bool isLeft;
}

abstract class GravitationalObject {
  GravitationalObject({
    this.position,
    this.gravitySpeed = 0.0,
    this.additionalForce = const Offset(0,0)
  });

  Offset position;
  double gravitySpeed;
  double _gravity = 1.0;
  Offset additionalForce;

  void applyGravity() {
    gravitySpeed += _gravity;
    position = Offset(
      position.dx + additionalForce.dx,
      position.dy + gravitySpeed + additionalForce.dy
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

我们创建了一个名为 `Fruit` 的新抽象类GravitationalObject,并让 `Fruit` 和 `FruitPart` 都继承自该类。`Fruit` 的构造函数参数包括GravitationalObject`_gravity` position、`_push`gravitySpeed和 `_push`。`_gravity`表示物体向下移动的力。每次调用 `_push` 方法时,该力都会增加 `_gravity` 的值,以模拟重力逐渐增大的效果。`_push`表示作用于该物体的任何其他力。如果我们不希望水果只是简单地向下掉落,而是希望它们被向上或向侧面“抛出”,那么 `_push` 就非常有用。我们还会利用 `_push` 来实现切开水果时,水果的各个部分能够分离的效果。additionalForcegravitySpeedapplyGravity()additionalForce

现在,要让引力开始产生效果,剩下的就是定期对水果施加力,更新它们的位置。

@override
void initState() {
  fruits.add(new Fruit(
    position: Offset(0, 200),
    width: 80,
    height: 80,
    additionalForce: Offset(5, -10)
  ));
  _tick();
  super.initState();
}

void _tick() {
  setState(() {
    for (Fruit fruit in fruits) {
      fruit.applyGravity();
    }
    for (FruitPart fruitPart in fruitParts) {
      fruitPart.applyGravity();
    }
  });

  Future.delayed(Duration(milliseconds: 30), _tick);
}

void _turnFruitIntoParts(Fruit hit) {
  FruitPart leftFruitPart = FruitPart(
      ...
      additionalForce: Offset(hit.additionalForce.dx - 1, hit.additionalForce.dy -5)
  );

  FruitPart rightFruitPart = FruitPart(
      ...
      additionalForce: Offset(hit.additionalForce.dx + 1, hit.additionalForce.dy -5)
  );
  ...
}
Enter fullscreen mode Exit fullscreen mode

我们创建了一个每 30 毫秒执行一次的新方法_tick(),用于更新水果的位置。最初显示的水果会获得一个额外的力,使其向上向右抛出。当水果被分割成多个部分时,我们会给每个部分施加一个方向相反的额外力。

Flutter Fruit Ninja 重力

细节决定成败。

游戏的基本机制已经完成了,接下来我们来改进一些细节。

首先,这个切面看起来不太美观,因为它只是一条线。让我们来制作一个真正的刀刃吧!

void _drawBlade(Canvas canvas, Size size) {
  Path pathLeft = Path();
  Path pathRight = Path();
  Paint paintLeft = Paint();
  Paint paintRight = Paint();

  if (pointsList.length < 3) {
    return;
  }

  paintLeft.color = Color.fromRGBO(220, 220, 220, 1);
  paintRight.color = Colors.white;
  pathLeft.moveTo(pointsList[0].dx, pointsList[0].dy);
  pathRight.moveTo(pointsList[0].dx, pointsList[0].dy);

  for (int i = 0; i < pointsList.length; i++) {
    if (pointsList[i] == null) {
      continue;
    }

    if (i <= 1 || i >= pointsList.length - 5) {
      pathLeft.lineTo(pointsList[i].dx, pointsList[i].dy);
      pathRight.lineTo(pointsList[i].dx, pointsList[i].dy);
      continue;
    }

    double x1 = pointsList[i-1].dx;
    double x2 = pointsList[i].dx;
    double lengthX = x2 - x1;

    double y1 = pointsList[i-1].dy;
    double y2 = pointsList[i].dy;
    double lengthY = y2 - y1;

    double length = sqrt((lengthX * lengthX) + (lengthY * lengthY));
    double normalizedVectorX = lengthX / length;
    double normalizedVectorY = lengthY / length;
    double distance = 15;

    double newXLeft = x1 - normalizedVectorY * (i / pointsList.length * distance);
    double newYLeft = y1 + normalizedVectorX * (i / pointsList.length * distance);

    double newXRight = x1 - normalizedVectorY * (i / pointsList.length  * -distance);
    double newYRight = y1 + normalizedVectorX * (i / pointsList.length * -distance);

    pathLeft.lineTo(newXLeft, newYLeft);
    pathRight.lineTo(newXRight, newYRight);
  }

  for (int i = pointsList.length - 1; i >= 0; i--) {
    if (pointsList[i] == null) {
      continue;
    }

    pathLeft.lineTo(pointsList[i].dx, pointsList[i].dy);
    pathRight.lineTo(pointsList[i].dx, pointsList[i].dy);
  }

  canvas.drawShadow(pathLeft, Colors.grey, 3.0, false);
  canvas.drawShadow(pathRight, Colors.grey, 3.0, false);
  canvas.drawPath(pathLeft, paintLeft);
  canvas.drawPath(pathRight, paintRight);
}
Enter fullscreen mode Exit fullscreen mode

这看起来比实际操作要复杂。我们在这里所做的,是绘制两条与手指路径平行的路径。这可以通过一些几何知识来实现​​。给定一个点,我们使用勾股定理计算它到前一个点的距离。然后,我们将各分量除以该长度。这样就得到了中心线与左侧路径之间的正交向量。取反后的值就是右侧路径对应的向量。

我们将它乘以当前索引除以点数再乘以我们设定的距离 15。这样,得到的就不是两条平行的曲线,而是两条向中心线方向逐渐变远的曲线。

为了闭合这两条路径,我们从最后一个点迭代到第一个点,并从一个点到另一个点画线,直到再次到达第一个点。

如果我们一次性生成多个水果,每个水果的旋转方向都会相同。让我们改变这种情况,给它们赋予随机旋转方向。

  List<Widget> _getFruits() {
    List<Widget> list = new List();

    for (Fruit fruit in fruits) {
      list.add(
        Positioned(
          top: fruit.position.dy,
          left: fruit.position.dx,
          child: Transform.rotate(
            angle: fruit.rotation * pi * 2,
            child: _getMelon(fruit)
          )
        )
      );
    }

    return list;
  }

  Widget _getMelonCut(FruitPart fruitPart) {
    return Transform.rotate(
      angle: fruitPart.rotation * pi * 2,
     ...
    );
  }

  void _turnFruitIntoParts(Fruit hit) {
    FruitPart leftFruitPart = FruitPart(
        ...
        rotation:  hit.rotation
    );

    FruitPart rightFruitPart = FruitPart(
      ...
      rotation:  hit.rotation
    );

class Fruit extends GravitationalObject {
  Fruit({
    ...
    rotation = 0.0
  }) : super(..., rotation: rotation);
}

class FruitPart extends GravitationalObject {
  FruitPart({
    ...
    rotation = 0.0
  }) : super(..., rotation: rotation);
}

abstract class GravitationalObject {
  GravitationalObject({
    ...
    this.rotation
  });

  ...
  double rotation;
  ...
}
Enter fullscreen mode Exit fullscreen mode

我们在代码中添加了一个新字段GravitationalObject:旋转角度。旋转角度是一个双精度浮点数,表示 360° 旋转的次数。然后,我们将显示水果及其各个部分的行包裹在一个 Transform.rotate 组件中,该组件的角度为旋转角度乘以 π * 2,因为它要求旋转角度以弧度为单位(其中 2 * π 表示 360° 旋转)。_turnFruitIntoParts()我们确保各个部分的旋转角度与原始水果保持一致,使其看起来更加自然。

Flutte 水果忍者克隆动画

稍微改了一下背景颜色,显示了一下分数,时不时地触发一下西瓜的生成,目前就到此为止了。接下来该怎么做,就看你的想象力了。

最后想说的

我们在未使用任何框架的情况下,实现了一个非常基础的《水果忍者》游戏版本。目前它只有切水果和收集积分的功能,但我相信大家一定有很多关于如何在此基础上进行扩展的想法。添加更多水果种类、水花四溅的效果、炸弹、关卡、高分榜、开始界面等等,都可以是下一步的方向。您可以在 GitHub 上找到完整的源代码:

https://github.com/flutter-clutter/flutter-fruit-ninja-clone

文章来源:https://dev.to/flutterclutter/flutter-game-tutorial-fruit-slicing-25fh