Flutter游戏教程:水果切片
目标
你将学到什么
实施
最后想说的
目标
本教程的目标是以基础的方式开发一款类似《水果忍者》的游戏。我们将不使用任何框架,以便读者可以从零开始学习其工作原理。
你将学到什么
完成本教程后,您将能够
- 使用手势检测器
- 在屏幕上作画
- 实现基本碰撞检查
- 实现一个基本的重力模拟
实施
我们游戏的基本版本包含以下几个需要解决的问题:
- 实现一个“切片器”,它可以沿着我们用手指滑动创建的路径进行切片。
- 实现水果的外观
- 利用重力使水果向下流动
- 检查切片机与水果是否发生碰撞
切片机
我们先来看拖动屏幕时应该出现的切片器:
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;
}
这个SlicePainter函数需要接收若干个点,并在屏幕上绘制这些点,并在点之间绘制连接线。为此,我们创建一个点集Path,将起始点移动到点集第一个元素的坐标,然后遍历每个元素(从第二个元素开始),并从前一个点到当前点绘制一条线。
CustomPainter如果一个对象没有被使用,它本身就没有价值。这就是为什么我们需要一个画布来识别手指滑动,捕捉这些点,并将它们放入我们新创建的对象的构造函数中CustomPainter,以便实际绘制路径。
List<Widget> _getStack() {
List<Widget> widgetsOnStack = List();
widgetsOnStack.add(_getSlice());
widgetsOnStack.add(_getGestureDetector());
return widgetsOnStack;
}
我们的组件由一个 组成Stack。底部将是我们滑动操作生成的切片,在其上方我们希望放置 ,GestureDetector因为我们不希望任何东西阻挡检测。
class TouchSlice {
TouchSlice({
this.pointsList
});
List<Offset> pointsList;
}
首先,我们创建一个模型类来表示切片。我们将其命名为TouchSlice,并使其接受一个Offset切片列表作为唯一参数。
Widget _getSlice() {
if (touchSlice == null) {
return Container();
}
return CustomPaint(
size: Size.infinite,
painter: SlicePainter(
pointsList: touchSlice.pointsList,
)
);
}
然后我们实现该_getSlice()方法,该方法返回一个对象,该对象根据控件实例CustomPaint的点列表绘制我们之前创建的切片。该对象始终为空。让我们通过添加一个.TouchSliceCanvasAreaTouchSliceGestureDetector
检测滑动手势
Widget _getGestureDetector() {
return GestureDetector(
onScaleStart: (details) {
setState(() {
_setNewSlice(details);
});
},
onScaleUpdate: (details) {
setState(() {
_addPointToSlice(details);
});
},
onScaleEnd: (details) {
setState(() {
touchSlice = null;
});
}
);
}
手势检测器监听三个事件:
onScaleStart这是我们开始滑动时触发的事件。这应该会向状态中添加一个新的 TouchSlice,该 TouchSlice 具有一个点。onScaleUpdat当我们在屏幕上移动手指时,就会调用此函数。这应该会在我们的现有点列表中添加一个新点。TouchSliceonScaleEnd当我们松开手指离开屏幕时,会调用此方法。这应该将值设置TouchSlice为 null,以便让切片消失。
让我们来实现这些方法吧!
void _setNewSlice(details) {
touchSlice = TouchSlice(pointsList: [details.localFocalPoint]);
}
void _addPointToSlice(ScaleUpdateDetails details) {
touchSlice.pointsList.add(details.localFocalPoint);
}
void _resetSlice() {
touchSlice = null;
}
测试时间到!
让我们通过构建和启动应用程序来看看实际效果如何。
哦!我们忘了限制可以绘制的线段的长度。让我们通过将线上的点数限制为 16 个来纠正它。
void _addPointToSlice(ScaleUpdateDetails details) {
if (touchSlice.pointsList.length > 16) {
touchSlice.pointsList.removeAt(0);
}
touchSlice.pointsList.add(details.localFocalPoint);
}
好的,如果点数超过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)
],
)
),
);
}
径向渐变应该能让整体效果不那么阴郁。
加入水果
好了,接下来就是最有趣的部分了!我们要在游戏中加入水果。
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;
}
}
我们的水果应该保持其位置,以便我们能在屏幕上绘制它,并在之后调整它的位置。它还应该有边界感知,因为我们需要检查切片是否碰到了它的边界。为了帮助我们确定这一点,我们创建了一个名为 `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;
}
为了存储屏幕上当前所有水果的数据,我们给组件状态添加了一个名为 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;
}
}
}
我们遍历一个从水果列表派生而来的新列表。对于每个水果,我们检查每个点是否在其内部。如果在其内部,则将该水果从内层循环中移除,Stack因为break如果发生碰撞,则无需检查其余点。
现在我们有了一个水果列表和一个显示它们的方法,但是列表中还没有水果。让我们通过Fruit在列表中添加一个水果来改变这种情况initState。
@override
void initState() {
fruits.add(new Fruit(
position: Offset(100, 100),
width: 80,
height: 80
));
super.initState();
}
太好了,我们可以在屏幕上画一条线,然后让一个矩形消失。不过有一点让我不太满意,就是我们一碰到它,它就立刻消失了。我们想要的是切开它的效果。所以,我们_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;
}
}
}
}
现在,只有当线上的一个点位于果实外部,第二个点位于果实内部,第三个点位于果实外部时,算法才会将移动判定为碰撞。这样可以确保发生类似切割之类的动作。
一个白色的长方形水果看起来不太诱人,而且也让人没有想切开的欲望。让我们换个更吸引人的图片来改变这一点吧。
我在设计和艺术方面没什么天赋。我尝试创作一些简单的矢量图形,来表现甜瓜的各个部分:一个完整的甜瓜、甜瓜的左右两半,以及一滴水花。
我们要注意,当西瓜出现时,要看到整个西瓜;切开后,要看到两部分。
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
);
}
我们先从简单的部分开始:替换白色矩形。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;
}
我们引入了一个名为 `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
);
}
}
我们创建了一个名为 `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)
);
...
}
我们创建了一个每 30 毫秒执行一次的新方法_tick(),用于更新水果的位置。最初显示的水果会获得一个额外的力,使其向上向右抛出。当水果被分割成多个部分时,我们会给每个部分施加一个方向相反的额外力。
细节决定成败。
游戏的基本机制已经完成了,接下来我们来改进一些细节。
首先,这个切面看起来不太美观,因为它只是一条线。让我们来制作一个真正的刀刃吧!
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);
}
这看起来比实际操作要复杂。我们在这里所做的,是绘制两条与手指路径平行的路径。这可以通过一些几何知识来实现。给定一个点,我们使用勾股定理计算它到前一个点的距离。然后,我们将各分量除以该长度。这样就得到了中心线与左侧路径之间的正交向量。取反后的值就是右侧路径对应的向量。
我们将它乘以当前索引除以点数再乘以我们设定的距离 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;
...
}
我们在代码中添加了一个新字段GravitationalObject:旋转角度。旋转角度是一个双精度浮点数,表示 360° 旋转的次数。然后,我们将显示水果及其各个部分的行包裹在一个 Transform.rotate 组件中,该组件的角度为旋转角度乘以 π * 2,因为它要求旋转角度以弧度为单位(其中 2 * π 表示 360° 旋转)。_turnFruitIntoParts()我们确保各个部分的旋转角度与原始水果保持一致,使其看起来更加自然。
稍微改了一下背景颜色,显示了一下分数,时不时地触发一下西瓜的生成,目前就到此为止了。接下来该怎么做,就看你的想象力了。
最后想说的
我们在未使用任何框架的情况下,实现了一个非常基础的《水果忍者》游戏版本。目前它只有切水果和收集积分的功能,但我相信大家一定有很多关于如何在此基础上进行扩展的想法。添加更多水果种类、水花四溅的效果、炸弹、关卡、高分榜、开始界面等等,都可以是下一步的方向。您可以在 GitHub 上找到完整的源代码:
https://github.com/flutter-clutter/flutter-fruit-ninja-clone
文章来源:https://dev.to/flutterclutter/flutter-game-tutorial-fruit-slicing-25fh








