فلاتر گزینهای رایج برای ساخت اپلیکیشنهای چندپلتفرمی با اینترفیسهای کاربری تمیز، انیمیشینهای روان و عملکرد بسیار سریع است. در این مقاله به بررسی روش طراحی و ساخت یک بازیپازل در فلاتر میپردازیم. این بازی از چند مفهوم و الگوی طراحی بهره میگیرد که میتوانید روی هر نوع اپلیکیشن که نیازمند UI پیچیده است به کار بگیرید.
برای ساخت و اجرای این پروژه به یک محیط فلاتر نیاز داریم. برای کسب اطلاعات در مورد روش راهاندازی محیط توسعه فلاتر به مستندات رسمی آن (+) مراجعه کنید. ریپازیتوری گیتهاب این پروژه را نیز میتوانید در این لینک (+) مشاهده کنید.
مفاهیم اصلی
چند مفهوم اصلی در زمینه محیط توسعه فلاتر وجود دارند که در طراحی این بازی ساده مورد استفاده قرار گرفتهاند و هر کدام از آنها نقشی مهم در مدیریت کلی حالت بازی و رندرینگ گرافیک آن دارند:
ویجتهای مقدماتی فلاتر از قبیل Container ،SizedBox ،Column و غیره.
استفاده از AnimationController، AnimatedWidget و Tween.
مدیریت حالت با ChangeNotifier / ChangeNotifierProvider.
مدیریت رویدادها و استفاده از Stream / StreamController.
دریافت ژستهای لمسی با Stream / StreamController.
در این بازی دمو ترکیبی از این مفاهیم برای تعریف منطق بازی، مدیریت حالت، تعاملها، رندرینگ و رفتار انیمیشن به روشی ساده و کارآمد پیادهسازی شدهاند تا یک طراحی تمیز و با حداقل سورس کد داشته باشیم.
خلاصه فرایند کار
منطق بازی شامل پنج فایل سورس است که هر یک وظایف مختلفی را اداره میکنند. این فایلها به شرح زیر هستند:
main.dart – مقداردهی اپلیکیشن و رندرینگ سطح بالای ویجت UI را بر عهده دارد.
game-board.dart – تشخیص ژستهای لمسی و رندرینگ صفحه بازی بر عهده این فایل است.
game-piece.dart – مدلسازی، رندرینگ و انیمیشنهای بخش بازی بر عهده این فایل است.
controller.dart – پردازش نوبتهای بازی، بهروزرسانی صفحه بازی و دیگر منطقهای مربوط به بازی در این فایل انجام میشود.
score.dart – این فایل امتیازات را ثبت کرده و زمانی که امتیازی تغییر یابد، نما را رفرش میکند.
طراحی پروژه نمونه کاملاً سرراست است و از مفاهیم مورد اشاره فوق برای انجام وظایفی مانند مدیریت حالت، اجرای انیمیشنها و کارهای دیگر استفاده میکند. صفحه بازی، مهرههای بازی را بر اساس موقعیتهای افقی و عمودی آنها روی صفحه رندر میکند. زمانی که یک ژست سوایپ تشخیص داده شود، یک نوبت بازی صورت میگیرد و زمانی که مهرهها ترکیب شوند، به مهرهای با امتیاز بالاتر تبدیل میشوند که شبیه به بازی 2048 است. کنترلر محاسبات صفحه را مدیریت میکند و با هر نوبت بازی، صفحه را بهروزرسانی کرده و در صورت نیاز امتیاز را نیز بهروز میکند.
هر مهره بازی یک موقعیت x/y روی صفحه دارد و مقداری بین 0 تا 6 بسته به هر کدام از هفت رنگ در طیف بینایی دارد. زمانی که یک مهره جابجا میشود، خود را به موقعیت جدیدی روی صفحه انیمیت میکند. وقتی که مهرههای همامتیاز با هم برخورد کنند، مهره هدف، حذف میشود و سپس مهره متحرک ارتقا یافته و به مکان مهره مقصد جابجا میشود.
نقطه ورودی اپلیکیشن
مقداردهی اولیه بازی و ساخت چارچوب UI در فایل main.dart صورت میگیرد:
ابتدا تابع main پیش از اعمال یک قفل جهتگیری روی حالت افقی، مطمئن میشود که ویجتهای فلاتر مقداردهی شدهاند و سپس اپلیکیشن را اجرا میکند. ویجت Game یک MaterialApp استاندارد با چارچوب UI با تمِِ تاریک و دکمه آغاز بازگشت میدهد. نکته خاص دیگری در این فایل وجود ندارد، زیرا اغلب بخشهای کار در کامپوننتهای دیگر انجام مییابد. در ادامه نمای امتیاز و صفحه بازی را بررسی میکنیم تا طراحی بازی را بهتر درک کنیم.
امتیاز بازی
کلاسهای امتیاز ساده هستند و چند مفهوم مورد استفاده در سراسر بازی را نمایش میدهند، از این رو به بررسی فایل score.dart میپردازیم:
امتیاز بازی با یک مدل و نمای مجزا به همراه ChangeNotifier که مدل را بسط داده است و نمای مصرفکننده مدل پیادهسازی میشود که به نما امکان میدهد تا به صورت خودکار در زمان تغییر یافت مدل بهروزرسانی شود. این کار از طریق فراخوانی ()notifyListeners درون بخش تعیین امتیاز صورت میگیرد که نوتیفکیشنی را روی ChangeNotifierProvider منتشر میسازد و موجب میشود که ویجت خود را از نو و با مقدار جدید امتیاز بسازد.
این وضعیت نمایانگر یک پیادهسازی مقدماتی از مدیریت حالت با provider است و یک روش تمیز برای بهروزرسانی نما در زمان تغییر یافتن حالت ارائه میکند.
فلاتر امکان استفاده از مشخصههای «تغییرپذیر» (mutable) را روی StatelessWidget نمیدهد و همچنین دسترسی مستقیمی به حالت StatelessWidget ارائه نکرده است. از این رو این یک راهحل مناسب برای بهروزرسانی حالت روی یک شیء و واداشتن ویجت به ساخت مجدد با شرایط مطلوب است.
صفحه بازی
مدیریت ژستها و رندرینگ صفحه در فایل game-board.dart صورت میگیرد:
صفحه بازی چند مشخصه و متد برای دریافت ورودی کاربر و رندر کردن محتوای بازی دریافت میکند. در طی اجرای ()initState یک شنونده رویداد برای دریافت رویدادهای بهروزرسانی از کنترلر و رسم مجدد UI راهاندازی میشود. از آنجا که کلاس Controller (که در ادامه بررسی خواهیم کرد) تنها مشخصههای استاتیک دارد و به یک وهله نیاز ندارد، از استریمها برای اطلاعرسانی دستی به شوندهها به جای الزام به ساخت یک وهله از ChangeNotifier استفاده میکند.
GestureDetector برای دریافت ورودی استفاده میشود که مقدار میانگین آن در طی کل رویداد pan دریافت میشود و در زمان کامل شدن عمل سوایپ به کنترل تحویل داده میشود. سپس برای ژست ورودی بعدی ریست میشود. این کار موجب حذف شدن ورودی میشود و کنترلر به روش آسانتری میتواند جهت مورد نظر را تفسیر کند.
یک صفحه بازی مربعی با ارتفاع و عرض برابر با صفحه دستگاه تعریف میشود و مهرههای بازی به عنوان فرزندان یک پشته تعریف میشوند که آنها را روی محور Z بر روی هم انباشت میکند. موقعیت رندرینگ X/Y هر مهره بازی به صورت داخلی درون کلاسهای مهره بازی با استفاده از مشخصههای همراستایی مدیریت میشود که در ادامه بررسی خواهیم کرد.
مهرههای بازی
کلاسهای مدیریت عملیات مهره بازی و رندرینگ ویجت درون فایل game-piece.dart قرار دارند:
سه کامپوننت عمدهی درون این فایل به شرح زیر هستند:
GamePieceModel – دادهها را مدیریت کرده و در زمان تغییر یافتن به شنوندهها اطلاع میدهد.
GamePieceModel – یک دایره را رندر میکند که در زمان جابجایی خود را انیمیت میکند.
GamePiece – کامپوننتهای GamePieceModel و GamePieceView را در یک ویجت قرار میدهد.
صفحه بازی یک فهرست از اشیای GamePiece رندر میکند که هر یک از آنها از سوی یک GamePieceModel پشتیبانی میشوند و همچنین رندرینگ GamePieceModel را موجب میشود.
هر GamePiece یک ویجت GamePieceView را مستقیماً درون پشته والد روی صفحه بازی رندر میکند و accessors-های get را برای افشای مشخصهها و متدهای مورد نیاز مدل عرضه میکند. بدین ترتیب ویجت GamePiece به عنوان یک اینترفیس برای مهره بازی و بقیه برنامه عمل میکند و وظیفه اجرای عملیات روی هر یک از آنها را تسهیل میسازد.
موقعیتیابی هر مهره درون پشته والد درون GamePieceView و با استفاده از دو مشخصه Align و FractionalOffset انجام میگیرد تا هر مهره به میزان ضریبی از یک-هفتم کل اندازه صفحه در هر دو محور x و y جابجا شود.
زمانی که یک مهره ایجاد میشود، یک وهله از GamePieceModel ارسال میشود تا موقعیت و مقدار مهره را ذخیره کند و جهت سوایپ قبلی از کنترلر گیم دریافت میشود تا مشخص شود که مهره جدید باید از کدام جهت صفحه به داخل بلغزد.
زمانی که یک مهره به موقعیت جدید جابجا شود، ChangeNotifierProvider درون ChangeNotifierProvider یک تغییر را دریافت میکند و ویجت را از نو ساخته و کنترلری که انیمیشن را بر عهده دارد آغاز کرده و ویجت را در روی صفحه حرکت میدهد. انیمیشن از سوی یک AnimationController روی حالت مهره اجرا میشود و از یک piece برای همگامسازی خود با کنترلر انیمیشن استفاده میکند که به GamePieceView ارسال میشود و در نهایت به AnimatedWidget میرود که آن را بسط میدهد. سازنده مربوط به GamePieceView، مقادیر انیمیتشده را برای x و y با استفاده از Tween و CurvedAnimation ایجاد میکند که یک مسیر انیمیشن از موقعیت قبلی به جدید میسازد. زمانی که شیء GamePieceView رندر شود، موقعیت قبلی روی موقعیت جاری تنظیم میشود تا از اجرای مجدد انیمیشن تا دفعه بعد که نیاز به جابجایی مهره باشد، جلوگیری کند.
کنترلر
با در نظر گرفتن همه مواردی که مطرح شد، فایل controller.dart به صورت زیر است:
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_puzzle_game_demo/game-piece.dart';
import 'package:flutter_puzzle_game_demo/score.dart';
enum Direction { UP, DOWN, LEFT, RIGHT, NONE }
class Controller { static ScoreModel score = ScoreModel(); static Random rnd = Random(); static List<GamePiece> _pieces = []; static Map<Point, GamePiece> index = {}; static get pieces => _pieces; static StreamController bus = StreamController.broadcast(); static StreamSubscription listen(Function handler) => bus.stream.listen(handler); static dispose() => bus.close(); static Direction lastDirection = Direction.RIGHT; static Direction parse(Offset offset) { if (offset.dx < 0 && offset.dx.abs() > offset.dy.abs()) return Direction.LEFT; if (offset.dx > 0 && offset.dx.abs() > offset.dy.abs()) return Direction.RIGHT; if (offset.dy < 0 && offset.dy.abs() > offset.dx.abs()) return Direction.UP; if (offset.dy > 0 && offset.dy.abs() > offset.dx.abs()) return Direction.DOWN; return Direction.NONE; } static addPiece(GamePiece piece) { _pieces.add(piece); index[piece.position] = piece; } static removePiece(GamePiece piece) { _pieces.remove(piece); index[piece.position] = null; } static void on(Offset offset) { lastDirection = parse(offset); process(lastDirection); bus.add(null); if (_pieces.length > 48) { start(); } // Game Over :/ Point p; while (p == null || index.containsKey(p)) { p = Point(rnd.nextInt(6), rnd.nextInt(6)); } addPiece(GamePiece(model: GamePieceModel(position: p, value: 0))); } static void process(Direction direction) { switch (direction) { case (Direction.UP): return scan(0, 7, 1, Axis.vertical); case (Direction.DOWN): return scan(6, -1, -1, Axis.vertical); case (Direction.LEFT): return scan(0, 7, 1, Axis.horizontal); case (Direction.RIGHT): return scan(6, -1, -1, Axis.horizontal); default: break; } } static scan(int start, int end, int op, Axis axis) { for (int j = start; j != end; j += op) { for (int k = 0; k != 7; k++) { Point p = axis == Axis.vertical ? Point(k, j) : Point(j, k); if (index.containsKey(p)) { check(start, op, axis, index[p]); } } } } static void check(int start, int op, Axis axis, GamePiece piece) { int target = (axis == Axis.vertical) ? piece.position.y : piece.position.x; for (var n = target - op; n != start - op; n -= op) { Point lookup = (axis == Axis.vertical) ? Point(piece.position.x, n) : Point(n, piece.position.y); if (!index.containsKey(lookup)) { target -= op; } else if (index[lookup].value == piece.value) { return merge(piece, index[lookup]); } else { break; } } Point destination = (axis == Axis.vertical) ? Point(piece.position.x, target) : Point(target, piece.position.y); if (destination != piece.position) { relocate(piece, destination); } } static void merge(GamePiece source, GamePiece target) { if (source.value == 6) { index.remove(source.position); index.remove(target.position); _pieces.remove(source); _pieces.remove(target); score.value += source.model.value * 100; return; } source.model.value += 1; index.remove(target.position); _pieces.remove(target); relocate(source, target.position); score.value += source.model.value * 10; } static void relocate(GamePiece piece, Point destination) { index.remove(piece.position); piece.move(destination); index[piece.position] = piece; } static void start() { _pieces = []; index = {}; on(Offset(1,0)); }
}
کلاس Controller بخش عمده منطق درون بازی را اجرا میکند که شامل مقداردهی بازی مدیریت ورودی و پردازش و ارزیابی نوبت بازیها است. یک مولد عدد تصادفی همراه با یک List<GamePiece> برای ذخیره مهرهها در بازی استفاده میشود و Map<Point, GamePiece> به عنوان یک اندیس گشتن به دنبال موقعیت X/Y عمل میکند. مشخصه bus یک پیادهسازی از Stream و StreamController برای انتشار یک رویداد در زمان پایان یافتن نوبت بازی استفاده میشود.
ورودی از طریق متد on دریافت میشود و با فراخوانی parse روی Offset دریافتی از رویداد به یک Direction تبدیل میشود. سپس این نوبت بازی ارزیابی میشود و رویداد pdate روی این بأس اجرا خواهد شد. اگر هر 49 فضای روی صفحه اشغال شوند، بازی ریاستارت میشود، در غیر این صورت یک مهره جدید به صفحه اضافه میشود و بازی آماده حرکت بعدی خواهد بود.
ارزیابی نوبت بازی با متد process صورت میگیرد که در آن process دریافت میشود و scan به همراه پارامترهای متناظر حلقه جهت ارزیابی صفحه در جهت عکس سوایپ فراخوانی میشود. این کار به دورترین آیتم در راستای محور هدف امکان میدهد که ابتدا مدیریت شود و به این ترتیب مهرهها در صورت ضرورت ادغام شده و یک مسیر در راستای صفحه برای بقیه مهرهها که باید ارزیابی شوند باز میشود.
متد scan یک آغاز، انتها، عملیات افزایش و محور میگیرد. یک حلقه با استفاده از این پارامترها برای آغاز اسکن کردن صفحه و گشتن به دنبال مهرههای بازی ساخته میشود. هر مهره که به check ارسال میشود، نقطه آغازین، عملیات افزایش، محور، مهره بازی به عنوان ورودی را دریافت میکند و شروع به برسی مسیر مورد نظر مهره میکند و در صورت نیاز آنها را ادغام کرده یا از نو ارائه میکند.
عملیات مختلف ادغام ابتدا از طریق بررسی این که مهرههایی که ادغام میشوند در بالاترین مقدار ممکن هستند یا نه آغاز میشود. اگر چنین باشد، هر دوی آنها حذف شده و یک مهره جایزه اضافه میشود. در غیر این صورت مهره هدف حذف میشود و مهره ورودی ارتقا یافته و به مکان مقصد جابجا میشود.
عملیات تخصیص مجدد به سادگی مهره را از اندیس به عنوان کلید کنونی حذف میکند و move را روی مهره فراخوانی میکند تا آن را بهروزرسانی کرده و یک انیمیشن را آغاز کند. در نهایت آیتم مجدداً در اندیس در کلید موقعیت z/y بهروزرسانی شده قرار میگیرد.
سخن پایانی
در این دمو بخشی از قدرت و انعطافپذیری موجود درون فلاتر را نشان دادیم و همچنین شیوه ساخت بازیهای 2 بعدی ساده با استفاده صرف از Flutter SDK و پکیج provider را تبیین کرده و همه موارد دیگر را از صفر تا صد، خودمان ساختیم.
مفاهیم مورد استفاده در این دمو میتوانند مستقیماً به هر نوع اپلیکیشن دیگر فلاتر که نیازمند مدیریت ساده حالت، UI با قابلیتهای تعاملی بالا، انیمیشنهای کارآمد و چرخههای رندر و همچنین معماری کلی تمیز که نیازمند تلاش اندکی برای نگهداری در آینده باشد، انتقال داد.
اگر این مطلب برای شما مفید بوده است، آموزشها و مطالب زیر نیز به شما پیشنهاد میشوند:
«میثم لطفی» دانشآموخته ریاضیات و شیفته فناوری به خصوص در حوزه رایانه است. وی در حال حاضر علاوه بر پیگیری همه علاقهمندیهای خود در رشتههای برنامهنویسی، کپیرایتینگ و تولید محتوای چندرسانهای، در زمینه نگارش مقالاتی با محوریت نرمافزار نیز با مجله فرادرس همکاری دارد.