ساخت بازی پازل با فلاتر – از صفر تا صد


در این مقاله قصد داریم با روش ساخت بازی پازل با فلاتر آشنا شویم. این بازی روی دستگاههای مجهز به سیستم عامل iOS و همچنین اندروید کار خواهد کرد. اگر فلاتر را روی سیستم خود نصب نکردهاید، هم اینک به وبسایت فلاتر (+) بروید و آن را نصب کنید.
تنظیم پروژه
زمانی که فلاتر را نصب کردید، باید یک پروژه جدید فلاتر در اندروید استودیو ایجاد کنید:
پس از ایجاد شدن پروژه آن را روی شبیهساز یا یک دستگاه واقعی اجرا کنید تا مطمئن شوید که همه چیز درست است.
برای پایان تنظیم پروژه فایل lib/main.dart را باز کنید و محتوای آن را با کد زیر عوض کنید. بدین ترتیب کارکرد نمونهای را که فلاتر در اپلیکیشن قرار داده است حذف کرده و آن را با نقطه آغازین اپلیکیشن خود جایگزین میکنیم.
1import 'package:flutter/material.dart';
2
3void main() => runApp(MyApp());
4
5class MyApp extends StatelessWidget {
6 @override
7 Widget build(BuildContext context) {
8 return MaterialApp(
9 title: 'Flutter Puzzle',
10 theme: ThemeData(
11 primarySwatch: Colors.blue,
12 ),
13 home: MyHomePage(title: 'Flutter Puzzle'),
14 );
15 }
16}
17
18class MyHomePage extends StatefulWidget {
19 final String title;
20
21 MyHomePage({Key key, this.title}) : super(key: key);
22
23 @override
24 _MyHomePageState createState() => _MyHomePageState();
25}
26
27class _MyHomePageState extends State<MyHomePage> {
28
29 @override
30 Widget build(BuildContext context) {
31 return Scaffold(
32 appBar: AppBar(
33 title: Text(widget.title),
34 ),
35 body: SafeArea(
36 child: new Center(
37 child: new Text('No image selected.'),
38 ),
39 ),
40 floatingActionButton: FloatingActionButton(
41 onPressed: () => null,
42 tooltip: 'New Image',
43 child: Icon(Icons.add),
44 ),
45 );
46 }
47}
چنان که مشاهده میکنید، این صرفاً یک صفحه خالی است. متن «No image selected» در میانه آن دیده میشود و یک دکمه شناور وجود دارد که در ادامه از آن استفاده خواهیم کرد:
گرفتن تصویر از دوربین یا گالری
اکنون که همه چیز سر جای خود است، کار خود را این گونه آغاز میکنیم که به اپلیکیشن امکان میدهیم تصاویری را از حافظه داخلی گوشی بارگذاری کند یا مستقیماً عکسهایی به وسیله دوربین گوشی بگیرد. به این منظور از یک افزونه فلاتر به نام image_picker بهره میگیریم.
نصب افزونههای جدید در فلاتر به سادگی اضافه کردن آنها به فایل pubspec.yaml است. سپس دستور flutter get را از کنسول یا مستقیماً از اندروید استودیو اجرا میکنیم:
1...
2dependencies:
3 flutter:
4 sdk: flutter
5
6 # The following adds the Cupertino Icons font to your application.
7 # Use with the CupertinoIcons class for iOS style icons.
8 cupertino_icons: ^0.1.2
9
10 image_picker: ^0.4.10
11...
از آنجا که قبلاً یک دکمه شناور اضافه کردهایم، به وسیله آن به کاربران امکان میدهیم که یک تصویر جدید انتخاب کنند. به این منظور باید تغییراتی در کد لحاظ کنیم. ابتدا گزینه انتخاب منبع تصویر را ارائه میکنیم که به صورت دوربین یا گالری است. تغییرات زیر را در فایل main.dart اعمال کنید:
1// ...
2
3class _MyHomePageState extends State<MyHomePage> {
4 @override
5 Widget build(BuildContext context) {
6 return Scaffold(
7 appBar: AppBar(
8 title: Text(widget.title),
9 ),
10 body: SafeArea(
11 child: new Center(
12 child: new Text('No image selected.'),
13 ),
14 ),
15 floatingActionButton: FloatingActionButton(
16 onPressed: () {
17 showModalBottomSheet(context: context,
18 builder: (BuildContext context) {
19 return SafeArea(
20 child: new Column(
21 mainAxisSize: MainAxisSize.min,
22 children: [
23 new ListTile(
24 leading: new Icon(Icons.camera),
25 title: new Text('Camera'),
26 onTap: () => null,
27 ),
28 new ListTile(
29 leading: new Icon(Icons.image),
30 title: new Text('Gallery'),
31 onTap: () => null,
32 ),
33 ],
34 ),
35 );
36 }
37 );
38 },
39 tooltip: 'New Image',
40 child: Icon(Icons.add),
41 ),
42 );
43 }
44 }
اکنون هنگامی که روی دکمه شناوری ضربه بزنید، منوی تحتانی ظاهر میشود و از شما میپرسد که منبع منتخب شما برای تصویر کدام است. مطمئن شوید که همه چیز به درستی کار میکند:
برای دریافت عملی تصاویر، یک متد جدید ایجاد میکنیم که وقتی کاربر هر کدام از گزینههای دوربین یا گالری فوق را انتخاب میکند فراخوانی خواهد شد. زمانی که تصویر با تغییر حالت اپلیکیشن بارگذاری شد، آن را به کاربر نمایش میدهیم. همچنین توجه داشته باشید که باید برخی کتابخانههای dart و افزونه image_picker را که اضافه کردهایم، ایمپورت کنیم:
1import 'dart:async';
2import 'dart:io';
3
4import 'package:flutter/material.dart';
5import 'package:image_picker/image_picker.dart';
6
7void main() => runApp(MyApp());
8
9// ...
10
11class _MyHomePageState extends State<MyHomePage> {
12 File _image;
13
14 Future getImage(ImageSource source) async {
15 var image = await ImagePicker.pickImage(source: source);
16
17 if (image != null) {
18 setState(() {
19 _image = image;
20 });
21 }
22 }
23
24 @override
25 Widget build(BuildContext context) {
26 return Scaffold(
27 appBar: AppBar(
28 title: Text(widget.title),
29 ),
30 body: SafeArea(
31 child: new Center(
32 child: _image == null
33 ? new Text('No image selected.')
34 : Image.file(_image)
35 ),
36 ),
37 floatingActionButton: FloatingActionButton(
38 onPressed: () {
39 showModalBottomSheet<void>(context: context,
40 builder: (BuildContext context) {
41 return SafeArea(
42 child: new Column(
43 mainAxisSize: MainAxisSize.min,
44 children: <Widget>[
45 new ListTile(
46 leading: new Icon(Icons.camera),
47 title: new Text('Camera'),
48 onTap: () {
49 getImage(ImageSource.camera);
50 // this is how you dismiss the modal bottom sheet after making a choice
51 Navigator.pop(context);
52 },
53 ),
54 new ListTile(
55 leading: new Icon(Icons.image),
56 title: new Text('Gallery'),
57 onTap: () {
58 getImage(ImageSource.gallery);
59 // dismiss the modal sheet
60 Navigator.pop(context);
61 },
62 ),
63 ],
64 ),
65 );
66 }
67 );
68 },
69 tooltip: 'New Image',
70 child: Icon(Icons.add),
71 ),
72 );
73 }
74}
آخرین کاری که باید انجام دهیم، افزودن جزییات مجوزهای خاص iOS است. به این منظور فایل ios/Runner/Info.plist را باز کنید و دو خط دیگر مانند زیر اضافه کنید:
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>CFBundleDevelopmentRegion</key>
6 <string>en</string>
7 ...
8 <key>NSPhotoLibraryUsageDescription</key>
9 <string>The app needs to access the Photo Library in order to be able to load images from it.</string>
10 <key>NSCameraUsageDescription</key>
11 <string>The app needs to access the Camera in order to be able to get images from it.</string>
12</dict>
13</plist>
اکنون میتوانید اپلیکیشن را تست کنید. در این زمان باید بتوانید روی هر دو پلتفرم تصاویر را بارگذاری کرده یا با دوربین گوشی عکسهایی بگیرید. زمانی که کار بررسی به پایان رسید، به بخش بعدی بروید.
تقسیم تصویر به تکههای پازل
ما برای ساخت تکههای پازل خود یک ویجت فلاتر به نام PuzzlePiece میسازیم. یک فایل جدید به نام PuzzlePiece.dart در پوشه lib ایجاد کنید.
این ویجت یک تصویر (image) میگیرد و آن را به مسیرهایی (path) برش میدهد که تکههای پازل را تشکیل میدهند. همچنین باید ردیف (row) و ستون (column) تکهای که رسم میشود و بیشینه تعداد ردیف/ستونها (maxRow و maxCol) را برای کل پازل بدانیم.
1import 'dart:math';
2
3import 'package:flutter/material.dart';
4
5class PuzzlePiece extends StatefulWidget {
6 final Image image;
7 final Size imageSize;
8 final int row;
9 final int col;
10 final int maxRow;
11 final int maxCol;
12
13 PuzzlePiece(
14 {Key key,
15 @required this.image,
16 @required this.imageSize,
17 @required this.row,
18 @required this.col,
19 @required this.maxRow,
20 @required this.maxCol})
21 : super(key: key);
22
23 @override
24 PuzzlePieceState createState() {
25 return new PuzzlePieceState();
26 }
27}
28
29class PuzzlePieceState extends State<PuzzlePiece> {
30 double top;
31 double left;
32
33 @override
34 Widget build(BuildContext context) {
35 final imageWidth = MediaQuery.of(context).size.width;
36 final imageHeight = MediaQuery.of(context).size.height * MediaQuery.of(context).size.width / widget.imageSize.width;
37 final pieceWidth = imageWidth / widget.maxCol;
38 final pieceHeight = imageHeight / widget.maxRow;
39
40 if (top == null) {
41 top = Random().nextInt((imageHeight - pieceHeight).ceil()).toDouble();
42 top -= widget.row * pieceHeight;
43 }
44 if (left == null) {
45 left = Random().nextInt((imageWidth - pieceWidth).ceil()).toDouble();
46 left -= widget.col * pieceWidth;
47 }
48
49 return Positioned(
50 top: top,
51 left: left,
52 width: imageWidth,
53 child: ClipPath(
54 child: CustomPaint(
55 foregroundPainter: PuzzlePiecePainter(widget.row, widget.col, widget.maxRow, widget.maxCol),
56 child: widget.image
57 ),
58 clipper: PuzzlePieceClipper(widget.row, widget.col, widget.maxRow, widget.maxCol),
59 ),
60 );
61 }
62}
63
64// this class is used to clip the image to the puzzle piece path
65class PuzzlePieceClipper extends CustomClipper<Path> {
66 final int row;
67 final int col;
68 final int maxRow;
69 final int maxCol;
70
71 PuzzlePieceClipper(this.row, this.col, this.maxRow, this.maxCol);
72
73 @override
74 Path getClip(Size size) {
75 return getPiecePath(size, row, col, maxRow, maxCol);
76 }
77
78 @override
79 bool shouldReclip(CustomClipper<Path> oldClipper) => false;
80}
81
82// this class is used to draw a border around the clipped image
83class PuzzlePiecePainter extends CustomPainter {
84 final int row;
85 final int col;
86 final int maxRow;
87 final int maxCol;
88
89 PuzzlePiecePainter(this.row, this.col, this.maxRow, this.maxCol);
90
91 @override
92 void paint(Canvas canvas, Size size) {
93 final Paint paint = Paint()
94 ..color = Color(0x80FFFFFF)
95 ..style = PaintingStyle.stroke
96 ..strokeWidth = 1.0;
97
98 canvas.drawPath(getPiecePath(size, row, col, maxRow, maxCol), paint);
99 }
100
101 @override
102 bool shouldRepaint(CustomPainter oldDelegate) {
103 return false;
104 }
105}
106
107// this is the path used to clip the image and, then, to draw a border around it; here we actually draw the puzzle piece
108Path getPiecePath(Size size, int row, int col, int maxRow, int maxCol) {
109 final width = size.width / maxCol;
110 final height = size.height / maxRow;
111 final offsetX = col * width;
112 final offsetY = row * height;
113 final bumpSize = height / 4;
114
115 var path = Path();
116 path.moveTo(offsetX, offsetY);
117
118 if (row == 0) {
119 // top side piece
120 path.lineTo(offsetX + width, offsetY);
121 } else {
122 // top bump
123 path.lineTo(offsetX + width / 3, offsetY);
124 path.cubicTo(offsetX + width / 6, offsetY - bumpSize, offsetX + width / 6 * 5, offsetY - bumpSize, offsetX + width / 3 * 2, offsetY);
125 path.lineTo(offsetX + width, offsetY);
126 }
127
128 if (col == maxCol - 1) {
129 // right side piece
130 path.lineTo(offsetX + width, offsetY + height);
131 } else {
132 // right bump
133 path.lineTo(offsetX + width, offsetY + height / 3);
134 path.cubicTo(offsetX + width - bumpSize, offsetY + height / 6, offsetX + width - bumpSize, offsetY + height / 6 * 5, offsetX + width, offsetY + height / 3 * 2);
135 path.lineTo(offsetX + width, offsetY + height);
136 }
137
138 if (row == maxRow - 1) {
139 // bottom side piece
140 path.lineTo(offsetX, offsetY + height);
141 } else {
142 // bottom bump
143 path.lineTo(offsetX + width / 3 * 2, offsetY + height);
144 path.cubicTo(offsetX + width / 6 * 5, offsetY + height - bumpSize, offsetX + width / 6, offsetY + height - bumpSize, offsetX + width / 3, offsetY + height);
145 path.lineTo(offsetX, offsetY + height);
146 }
147
148 if (col == 0) {
149 // left side piece
150 path.close();
151 } else {
152 // left bump
153 path.lineTo(offsetX, offsetY + height / 3 * 2);
154 path.cubicTo(offsetX - bumpSize, offsetY + height / 6 * 5, offsetX - bumpSize, offsetY + height / 6, offsetX, offsetY + height / 3);
155 path.close();
156 }
157
158 return path;
159}
اگر به فایل main.dart بازگردیم، کد زیر را به ابتدای کدهای موجود میافزاییم تا ویجت جدید فعال شود:
1import 'dart:async';
2import 'dart:io';
3
4import 'package:flutter/material.dart';
5import 'package:image_picker/image_picker.dart';
6
7import 'package:flutter_puzzle/PuzzlePiece.dart';
8
9void main() => runApp(MyApp());
10
11class MyApp extends StatelessWidget {
12 @override
13 Widget build(BuildContext context) {
14 return MaterialApp(
15 title: 'Flutter Puzzle',
16 theme: ThemeData(
17 primarySwatch: Colors.blue,
18 ),
19 home: MyHomePage(title: 'Flutter Puzzle'),
20 );
21 }
22}
23
24class MyHomePage extends StatefulWidget {
25 final String title;
26 final int rows = 3;
27 final int cols = 3;
28
29 MyHomePage({Key key, this.title}) : super(key: key);
30
31 @override
32 _MyHomePageState createState() => _MyHomePageState();
33}
34
35class _MyHomePageState extends State<MyHomePage> {
36 File _image;
37 List<Widget> pieces = [];
38
39 Future getImage(ImageSource source) async {
40 var image = await ImagePicker.pickImage(source: source);
41
42 if (image != null) {
43 setState(() {
44 _image = image;
45 pieces.clear();
46 });
47
48 splitImage(Image.file(image));
49 }
50 }
51
52 // we need to find out the image size, to be used in the PuzzlePiece widget
53 Future<Size> getImageSize(Image image) async {
54 final Completer<Size> completer = Completer<Size>();
55
56 image.image.resolve(const ImageConfiguration()).addListener(
57 (ImageInfo info, bool _) {
58 completer.complete(Size(
59 info.image.width.toDouble(),
60 info.image.height.toDouble(),
61 ));
62 },
63 );
64
65 final Size imageSize = await completer.future;
66
67 return imageSize;
68 }
69
70 // here we will split the image into small pieces using the rows and columns defined above; each piece will be added to a stack
71 void splitImage(Image image) async {
72 Size imageSize = await getImageSize(image);
73
74 for (int x = 0; x < widget.rows; x++) {
75 for (int y = 0; y < widget.cols; y++) {
76 setState(() {
77 pieces.add(PuzzlePiece(key: GlobalKey(),
78 image: image,
79 imageSize: imageSize,
80 row: x,
81 col: y,
82 maxRow: widget.rows,
83 maxCol: widget.cols));
84 });
85 }
86 }
87 }
88
89 @override
90 Widget build(BuildContext context) {
91 return Scaffold(
92 appBar: AppBar(
93 title: Text(widget.title),
94 ),
95 body: SafeArea(
96 child: new Center(
97 child: _image == null
98 ? new Text('No image selected.')
99 : Stack(children: pieces)
100 ),
101 ),
102 floatingActionButton: FloatingActionButton(
103 onPressed: () {
104 showModalBottomSheet<void>(context: context,
105 builder: (BuildContext context) {
106 return SafeArea(
107 child: new Column(
108 mainAxisSize: MainAxisSize.min,
109 children: <Widget>[
110 new ListTile(
111 leading: new Icon(Icons.camera),
112 title: new Text('Camera'),
113 onTap: () {
114 getImage(ImageSource.camera);
115 Navigator.pop(context);
116 },
117 ),
118 new ListTile(
119 leading: new Icon(Icons.image),
120 title: new Text('Gallery'),
121 onTap: () {
122 getImage(ImageSource.gallery);
123 Navigator.pop(context);
124 },
125 ),
126 ],
127 ),
128 );
129 }
130 );
131 },
132 tooltip: 'New Image',
133 child: Icon(Icons.add),
134 ),
135 );
136 }
137}
اینک اگر اپلیکیشن را اجرا و تصویر را بارگذاری کنید، میبینید که به چندین تکه پازل تبدیل میشود که به صورت تصادفی روی صفحه قرار گرفتهاند.
جابجایی تکههای پازل و چسباندن آنها به هم
در این بخش یک «شناساگر ژست» (GestureDetector) فلاتر به پروژه خود اضافه میکنیم تا کاربر بتواند تکههای پازل را روی صفحه جابجا کند. وقتی که یک پازل به موقعیت نهایی خود نزدیک میشود، به آن میچسبد و دیگر نمیتوان آن را جابجا کرد.
به این منظور کد هایلایت شده زیر ویجت PuzzlePiece را اضافه کنید:
1class PuzzlePiece extends StatefulWidget {
2 final Image image;
3 final Size imageSize;
4 final int row;
5 final int col;
6 final int maxRow;
7 final int maxCol;
8 final Function bringToTop;
9 final Function sendToBack;
10
11 PuzzlePiece(
12 {Key key,
13 @required this.image,
14 @required this.imageSize,
15 @required this.row,
16 @required this.col,
17 @required this.maxRow,
18 @required this.maxCol,
19 @required this.bringToTop,
20 @required this.sendToBack})
21 : super(key: key);
22
23 @override
24 PuzzlePieceState createState() {
25 return new PuzzlePieceState();
26 }
27}
28
29class PuzzlePieceState extends State<PuzzlePiece> {
30 double top;
31 double left;
32 bool isMovable = true;
33
34 @override
35 Widget build(BuildContext context) {
36 final imageWidth = MediaQuery.of(context).size.width;
37 final imageHeight = MediaQuery.of(context).size.height * MediaQuery.of(context).size.width / widget.imageSize.width;
38 final pieceWidth = imageWidth / widget.maxCol;
39 final pieceHeight = imageHeight / widget.maxRow;
40
41 if (top == null) {
42 top = Random().nextInt((imageHeight - pieceHeight).ceil()).toDouble();
43 top -= widget.row * pieceHeight;
44 }
45 if (left == null) {
46 left = Random().nextInt((imageWidth - pieceWidth).ceil()).toDouble();
47 left -= widget.col * pieceWidth;
48 }
49
50 return Positioned(
51 top: top,
52 left: left,
53 width: imageWidth,
54 child: GestureDetector(
55 onTap: () {
56 if (isMovable) {
57 widget.bringToTop(widget);
58 }
59 },
60 onPanStart: (_) {
61 if (isMovable) {
62 widget.bringToTop(widget);
63 }
64 },
65 onPanUpdate: (dragUpdateDetails) {
66 if (isMovable) {
67 setState(() {
68 top += dragUpdateDetails.delta.dy;
69 left += dragUpdateDetails.delta.dx;
70
71 if(-10 < top && top < 10 && -10 < left && left < 10) {
72 top = 0;
73 left = 0;
74 isMovable = false;
75 widget.sendToBack(widget);
76 }
77 });
78 }
79 },
80 child: ClipPath(
81 child: widget.image,
82 clipper: PuzzlePieceClipper(widget.row, widget.col, widget.maxRow, widget.maxCol),
83 ),
84 ),
85 );
86 }
87}
در نهایت کد زیر را به فایل main.dart اضافه کنید:
1void splitImage(Image image) async {
2 Size imageSize = await getImageSize(image);
3
4 for (int x = 0; x < widget.rows; x++) {
5 for (int y = 0; y < widget.cols; y++) {
6 setState(() {
7 pieces.add(PuzzlePiece(key: GlobalKey(),
8 image: image,
9 imageSize: imageSize,
10 row: x,
11 col: y,
12 maxRow: widget.rows,
13 maxCol: widget.cols,
14 bringToTop: this.bringToTop,
15 sendToBack: this.sendToBack));
16 });
17 }
18 }
19}
20
21// when the pan of a piece starts, we need to bring it to the front of the stack
22void bringToTop(Widget widget) {
23 setState(() {
24 pieces.remove(widget);
25 pieces.add(widget);
26 });
27}
28
29// when a piece reaches its final position, it will be sent to the back of the stack to not get in the way of other, still movable, pieces
30void sendToBack(Widget widget) {
31 setState(() {
32 pieces.remove(widget);
33 pieces.insert(0, widget);
34 });
35}
اپلیکیشن را یک بار دیگر اجرا کنید، تصویری را انتخاب کرده و تلاش کنید پازل را تکمیل کنید. اکنون همه چیز به خویی کار میکند و میتوانید از اپلیکیشنی که ساختهاید بهره بگیرید.
اما کارهای دیگری نیز وجود دارد که میتوانید برای بهبود بازی انجام دهید. یک نکته بدیهی آن است که وقتی همه تکههای پازل در جای خود قرار گرفتند، بازی را ریاستارت کنید. نکته دیگر این است که بازی را به حالت پرتره محدود کنید. ما ترجیح میدهیم اجرای این وظایف را به عنوان تمرین بر عهده شما بگذاریم. توجه کنید که برای اجرای کار دوم باید اپلیکیشن را برای پلتفرمهای iOS و Android به صورت مجزا پیکربندی کنید.
امیدوارم از این راهنما در مورد ساخت بازی پازل با فلاتر استفاده برده باشید؛ برای مشاهده کد نهایی پروژه به این ریپوی گیتهاب (+) مراجعه کنید.
اگر این مطلب برای شما مفید بوده است، آموزشهای زیر نیز به شما پیشنهاد میشوند:
- مجموعه آموزشهای برنامهنویسی اندروید
- مجموعه آموزشهای برنامهنویسی
- مجموعه آموزشهای دروس علوم و مهندسی کامپیوتر
- مفاهیم مقدماتی فلاتر (Flutter) — به زبان ساده
- گوگل فلاتر (Flutter) از صفر تا صد — ساخت اپلیکیشن به کمک ویجت
- ایجاد انیمیشن اسکرول در فلاتر (Flutter) — از صفر تا صد
==
اسم برنامه چیه؟