1. مقدمة
Flame هو محرّك ألعاب ثنائي الأبعاد يستند إلى Flutter. في هذا الدرس التطبيقي حول الترميز، ستنشئ لعبة مستوحاة من إحدى ألعاب الفيديو الكلاسيكية من فترة السبعينيات، وهي Breakout من تصميم ستيف وزنياك. ستستخدم مكونات Flame لرسم المضرب والكرة والطوب. عليك استخدام تأثيرات Flame لتحريك حركة الخفاش والاطّلاع على كيفية دمج Flame مع نظام إدارة الحالة في Flutter.
عند اكتمال اللعبة، من المفترض أن تبدو لعبتك مثل هذه الصورة المتحركة بتنسيق GIF، حتى لو كانت أبطأ.
ما ستتعرَّف عليه
- آلية عمل أساسيات تطبيق Flame بدءًا من
GameWidget
- كيفية استخدام حلقة الألعاب
- آلية عمل
Component
's Flame وهي تشبه عناصرWidget
في Flutter. - كيفية التعامل مع التصادمات.
- طريقة استخدام
Effect
لإضافة تأثيرات متحركة إلى رموزComponent
- كيفية تركيب Flutter
Widget
في أعلى لعبة Flame - كيفية دمج Flame مع إدارة حالات Flutter
ما الذي ستقوم ببنائه
في هذا الدرس التطبيقي حول الترميز، ستنشئ لعبة ثنائية الأبعاد باستخدام Flutter وFlutter. عند اكتمال اللعبة، يجب أن تستوفي اللعبة المتطلبات التالية
- تعمل على جميع الأنظمة الأساسية الست المتوافقة مع Flutter، وهي: Android وiOS وLinux وmacOS وWindows والويب.
- الحفاظ على معدّل 60 لقطة في الثانية على الأقل باستخدام حلقة ألعاب Flame
- ويمكنك استخدام إمكانيات Flutter، مثل حزمة
google_fonts
وflutter_animate
، لإضفاء أجواء حماسية على ألعاب كلاسيكية في الثمانينيات.
2. إعداد بيئة Flutter
محرِّر
لتبسيط هذا الدرس التطبيقي حول الترميز، تفترض أنّ Visual Studio Code (VS Code) هو بيئة التطوير التي تستخدمها. رمز VS Code مجاني ويعمل على جميع الأنظمة الأساسية الرئيسية. نستخدم هذا الدرس التطبيقي حول الترميز VS Code، لأنّ التعليمات يتم ضبطها تلقائيًا على الاختصارات الخاصة بـ VS Code. تصبح المهام أكثر وضوحًا: "انقر على هذا الزر" أو "اضغط على هذا المفتاح لإجراء X" بدلاً من "افعل الإجراء المناسب في المحرر لتنفيذ X".
يمكنك استخدام أي محرّر تريده: "استوديو Android" أو برامج IntelliJ IDE أخرى أو Emacs أو Vim أو Notepad++. وتعمل جميعها مع Flutter.
اختيار هدف التطوير
تُنتج شركة Flutter تطبيقات لأنظمة أساسية متعدّدة. يمكن تشغيل تطبيقك على أي من أنظمة التشغيل التالية:
- iOS
- Android
- Windows
- نظام التشغيل Mac
- Linux
- الويب
من الشائع اختيار نظام تشغيل واحد كهدف للتطوير. هذا هو نظام التشغيل الذي يعمل عليه تطبيقك أثناء تطويره.
على سبيل المثال، لنفترض أنّك تستخدم كمبيوتر محمول يعمل بنظام التشغيل Windows لتطوير تطبيق Flutter. يمكنك بعد ذلك اختيار Android كهدف للتطوير. لمعاينة تطبيقك، عليك توصيل جهاز Android بالكمبيوتر المحمول الذي يعمل بنظام التشغيل Windows باستخدام كابل USB، ويتم تطوير التطبيق على جهاز Android المتصل أو من خلال محاكي Android. ربما اخترت نظام التشغيل Windows كهدف للتطوير، والذي يشغّل تطوير تطبيقك أثناء تطويره كتطبيق Windows إلى جانب المحرّر.
قد تميل إلى اختيار الويب كهدف للتطوير. ويمثّل ذلك جانبًا سلبيًا أثناء عملية التطوير، حيث ستفقد إمكانية إعادة التحميل باستخدام الحالة (Stateful Hot Reload) في Flutter. يتعذّر على Flutter حاليًا إعادة تحميل تطبيقات الويب على الفور.
يُرجى تحديد اختيارك قبل المتابعة. يمكنك دائمًا تشغيل تطبيقك على أنظمة تشغيل أخرى لاحقًا. اختيار هدف التطوير يجعل الخطوة التالية أكثر سلاسة.
تثبيت Flutter
يمكنك الاطّلاع على أحدث التعليمات حول تثبيت حزمة تطوير البرامج (SDK) باستخدام Flutter على docs.flutter.dev.
تتناول التعليمات الواردة في موقع Flutter الإلكتروني عملية تثبيت حزمة تطوير البرامج (SDK) والأدوات ذات الصلة بهدف التطوير والمكوّنات الإضافية للمحرّر. بالنسبة لهذا الدرس التطبيقي حول الترميز، ثبِّت البرنامج التالي:
- حزمة تطوير البرامج (SDK) مع Flutter
- رمز Visual Studio مع المكوّن الإضافي Flutter
- برنامج التحويل البرمجي لهدف التطوير الذي اخترته (يجب استخدام Visual Studio لاستهداف نظام التشغيل Windows أو Xcode لاستهداف نظام التشغيل macOS أو iOS)
في القسم التالي، عليك إنشاء مشروعك الأول على Flutter.
إذا كنت بحاجة إلى استكشاف أي مشكلات وإصلاحها، فقد تجد بعض هذه الأسئلة والإجابات (من StackOverflow) مفيدة في استكشاف الأخطاء وإصلاحها.
الأسئلة الشائعة
- كيف يمكنني العثور على مسار حزمة Flutter SDK؟
- ماذا أفعل في حال عدم العثور على أمر Flutter؟
- كيف يمكنني إصلاح الخطأ "في انتظار أمر Flutter آخر لفتح قفل بدء التشغيل" المشكلة؟
- كيف يمكنني إعلام Flutter بمكان تثبيت حزمة تطوير البرامج (SDK) لنظام التشغيل Android؟
- كيف أتعامل مع خطأ Java عند تشغيل
flutter doctor --android-licenses
؟ - كيف أتعامل مع أداة Android
sdkmanager
التي لم يتم العثور عليها؟ - كيف أتعامل مع "المكوِّن
cmdline-tools
غير متوفّر" خطأ؟ - كيف يمكنني تشغيل CocoaPods على Apple Silicon (M1)؟
- كيف يمكنني إيقاف التنسيق التلقائي عند الحفظ في رمز VS؟
3- إنشاء مشروع
إنشاء مشروعك الأول على Flutter
ويشمل ذلك فتح رمز VS وإنشاء نموذج تطبيق Flutter في دليل من اختيارك.
- تفعيل Visual Studio Code.
- افتح لوحة الأوامر (
F1
أوCtrl+Shift+P
أوShift+Cmd+P
) ثم اكتب "Flutter new". عند ظهوره، اختَر الأمر Flutter: مشروع جديد.
- اختَر إفراغ التطبيق. اختر الدليل الذي تريد إنشاء مشروعك فيه. ويجب أن يكون هذا الدليل أي دليل لا يتطلب أذونات مميزة وعالية المستوى أو يحتوي على مسافة في مساره. وتشمل الأمثلة الدليل الرئيسي أو
C:\src\
.
- أدخِل اسمًا لمشروعك "
brick_breaker
". تفترض بقية هذه الدروس التطبيقية حول الترميز أنّك أطلقت اسم التطبيق "brick_breaker
".
ينشئ Flutter الآن مجلد المشاريع ويفتحه رمز VS. ستقوم الآن بالكتابة فوق محتويات ملفين باستخدام مخزن أساسي للتطبيق.
نسخ & لصق التطبيق الأولي
يؤدي ذلك إلى إضافة الرمز النموذجي المتوفّر في هذا الدرس التطبيقي حول الترميز إلى تطبيقك.
- في الجزء الأيمن من رمز VS، انقر على Explorer (المستكشف) وافتح ملف
pubspec.yaml
.
- استبدِل محتوى هذا الملف بما يلي:
pubspec.yaml
name: brick_breaker
description: "Re-implementing Woz's Breakout"
publish_to: 'none'
version: 0.1.0
environment:
sdk: '>=3.3.0 <4.0.0'
dependencies:
flame: ^1.16.0
flutter:
sdk: flutter
flutter_animate: ^4.5.0
google_fonts: ^6.1.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.1
flutter:
uses-material-design: true
يحدِّد ملف pubspec.yaml
المعلومات الأساسية عن تطبيقك، مثل إصداره الحالي وتبعياته ومواد العرض التي سيتم شحنه بها.
- افتح ملف
main.dart
في الدليلlib/
.
- استبدِل محتوى هذا الملف بما يلي:
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
void main() {
final game = FlameGame();
runApp(GameWidget(game: game));
}
- شغِّل هذا الرمز للتأكّد من أنّ كل شيء يعمل على النحو المطلوب. من المفترض أن تعرض نافذة جديدة بخلفية سوداء فارغة فقط. يتم عرض أسوأ لعبة فيديو في العالم الآن بمعدل 60 لقطة في الثانية!
4. إنشاء اللعبة
تكبير حجم اللعبة
تحتاج اللعبة التي يتم تشغيلها في بُعدين (ثنائي الأبعاد) إلى منطقة لعب. ستقوم ببناء منطقة بأبعاد محددة ثم استخدام هذه الأبعاد لتحديد حجم الجوانب الأخرى للعبة.
هناك طرق مختلفة لتصميم الإحداثيات في منطقة اللعب. من خلال إحدى الاصطلاحات، يمكنك قياس الاتجاه من وسط الشاشة مع أن يكون المصدر (0,0)
في منتصف الشاشة، وتنقل القيم الموجبة العناصر إلى اليمين على طول المحور "س" وأعلى على طول المحور "ص". ينطبق هذا المعيار على معظم الألعاب الحالية في الوقت الحالي، خاصةً عند الألعاب التي تتضمّن ثلاثة سمات.
كان التنسيق المتعارف عليه عند إنشاء لعبة Breakout الأصلية هو تحديد المصدر في أعلى يمين الشاشة. ظلّ اتجاه x الموجب كما هو، بينما قلب y. كان اتجاه x الموجب إلى اليمين واتجاه y لأسفل. أبرِز مهارتك في هذا العصر.
أنشئ ملفًا باسم config.dart
في دليل جديد باسم lib/src
. سيكتسب هذا الملف المزيد من الثوابت في الخطوات التالية.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
سيكون عرض هذه اللعبة 820 بكسل وارتفاع 1600 بكسل. يتم تغيير حجم منطقة اللعبة لتلائم النافذة التي يتم عرضها فيها، ولكن جميع المكونات المضافة إلى الشاشة تتوافق مع هذا الارتفاع والعرض.
إنشاء PlayArea
في لعبة Breakout، ترتد الكرة من جدران منطقة اللعب. لاستيعاب حالات الاصطدام، عليك أولاً مكوّن PlayArea
.
- أنشئ ملفًا باسم
play_area.dart
في دليل جديد باسمlib/src/components
. - أضف ما يلي إلى هذا الملف.
lib/src/components/play_area.dart
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
PlayArea()
: super(
paint: Paint()..color = const Color(0xfff2e8cf),
);
@override
FutureOr<void> onLoad() async {
super.onLoad();
size = Vector2(game.width, game.height);
}
}
في حال كان Flutter يحتوي على Widget
، يشمل Flame Component
قيمة. عندما تعتمد تطبيقات Flutter على إنشاء أشجار من التطبيقات المصغّرة، تعتمد ألعاب Flame على الحفاظ على أشجار مكوّنات من عناصرها.
يكمن هناك اختلاف كبير بين Flutter وFloome. شجرة التطبيقات المصغّرة في Flutter هي وصف مؤقت تم إنشاؤه ليُستخدَم لتعديل طبقة RenderObject
الثابتة والقابلة للتغيير. يُرجى العلم أنّ مكونات Flame ثابتة وقابلة للتغيير، ويُتوقّع أن يستخدم المطوّر هذه المكوّنات كجزء من نظام المحاكاة.
تم تحسين مكوّنات Flame للتعبير عن آليات اللعب. سيبدأ هذا الدرس التطبيقي حول الترميز مع "حلقة الألعاب" الواردة في الخطوة التالية.
- للتحكم في الفوضى، أضف ملفًا يحتوي على جميع المكونات في هذا المشروع. أنشئ ملف
components.dart
فيlib/src/components
وأضِف المحتوى التالي.
lib/src/components/components.dart
export 'play_area.dart';
يؤدي التوجيه export
الدور العكسي للدالة import
. يوضح هذا الملف الوظائف التي يعرضها هذا الملف عند استيراده إلى ملف آخر. سيزيد هذا الملف من عدد الإدخالات عند إضافة مكوّنات جديدة في الخطوات التالية.
إنشاء لعبة Flame
لإبعاد الخطوط الحمراء من الخطوة السابقة، استنبط فئة فرعية جديدة لـ FlameGame
لـ Flame.
- أنشئ ملفًا باسم
brick_breaker.dart
فيlib/src
وأضِف الرمز التالي.
lib/src/brick_breaker.dart
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
}
}
ينسق هذا الملف لإجراءات اللعبة. أثناء إنشاء مثيل اللعبة، يضبط هذا الرمز اللعبة لاستخدام العرض بدقة ثابتة. يتم تغيير حجم اللعبة لملء الشاشة التي تحتوي عليها وإضافة التأطير حسب الحاجة.
يمكنك عرض عرض اللعبة وارتفاعها حتى تتمكن المكوّنات الثانوية، مثل PlayArea
، من ضبط نفسها على الحجم المناسب.
في طريقة onLoad
التي تم تجاوزها، ينفِّذ الرمز إجراءَين.
- لضبط الجزء العلوي الأيسر كنقطة ارتساء لعدسة الكاميرا. وفقًا للإعدادات التلقائية، تستخدم عدسة الكاميرا منتصف المنطقة كنقطة ارتساء لـ
(0,0)
. - لإضافة السمة
PlayArea
إلى السمةworld
العالم يمثل عالم الألعاب. وهو يعرض كل عناصره الثانوية من خلال تحويل طريقة العرض فيCameraComponent
.
عرض اللعبة على الشاشة
للاطّلاع على كل التغييرات التي أجريتها في هذه الخطوة، عدِّل ملف lib/main.dart
بالتغييرات التالية.
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'src/brick_breaker.dart'; // Add this import
void main() {
final game = BrickBreaker(); // Modify this line
runApp(GameWidget(game: game));
}
بعد إجراء هذه التغييرات، أعِد تشغيل اللعبة. يجب أن تشبه اللعبة الشكل التالي.
في الخطوة التالية، ستضيف كرة إلى العالم، وتجعلها تتحرك!
5- عرض الكرة
إنشاء مكوِّن الكرة
ينطوي وضع كرة متحركة على الشاشة على إنشاء مكون آخر وإضافته إلى عالم اللعبة.
- عدِّل محتوى ملف
lib/src/config.dart
على النحو التالي.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02; // Add this constant
سيتم عرض نمط تصميم تحديد الثوابت المسماة كقيم مشتقة عدة مرات في هذا الدرس التطبيقي حول الترميز. يتيح لك هذا الإجراء تعديل المستوى الأعلى في gameWidth
وgameHeight
للتعرّف على التغييرات التي طرأت على اللعبة وأسلوبها.
- أنشِئ المكوِّن
Ball
في ملف باسمball.dart
فيlib/src/components
.
lib/src/components/ball.dart
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
class Ball extends CircleComponent {
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill);
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
}
في وقت سابق، حددت PlayArea
باستخدام RectangleComponent
، لذا فهي تعني وجود المزيد من الأشكال. CircleComponent
، مثل RectangleComponent
، مشتق من PositionedComponent
، لذا يمكنك وضع الكرة على الشاشة. والأهم من ذلك هو أنّ موضعها يمكن تعديله.
يقدم هذا المكوِّن مفهوم velocity
، أو يتغير الموضع بمرور الوقت. السرعة هي كائن Vector2
لأنّ السرعة هي السرعة والاتجاه. لتعديل الموضع، عليك إلغاء طريقة update
التي يستدعيها محرّك اللعبة لكل إطار. وdt
هي المدة بين الإطار السابق وهذا الإطار. ويتيح لك ذلك التكيّف مع عوامل مثل عدد اللقطات المختلفة في الثانية (60 هرتز أو 120 هرتز) أو اللقطات الطويلة بسبب الحوسبة الزائدة.
يُرجى الانتباه جيدًا إلى تحديث "position += velocity * dt
". وهذه هي طريقة تنفيذ تحديث المحاكاة المنفصلة للحركة بمرور الوقت.
- لتضمين المكوِّن
Ball
في قائمة المكوّنات، عدِّل ملفlib/src/components/components.dart
على النحو التالي.
lib/src/components/components.dart
export 'ball.dart'; // Add this export
export 'play_area.dart';
إضافة الكرة إلى العالم
لديك كرة. لنضعه في العالم ونعدّه للتنقل في منطقة اللعب.
عدِّل ملف lib/src/brick_breaker.dart
على النحو التالي.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math; // Add this import
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random(); // Add this variable
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(Ball( // Add from here...
radius: ballRadius,
position: size / 2,
velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
.normalized()
..scale(height / 4)));
debugMode = true; // To here.
}
}
يؤدي هذا التغيير إلى إضافة المكوِّن Ball
إلى world
. لضبط position
للكرة على وسط مساحة العرض، يقسِّم الرمز أولاً نصف حجم اللعبة، لأنّ Vector2
به أحمال زائدة لمشغّلي اللعبة (*
و/
) بهدف قياس Vector2
بقيمة عددية.
لضبط velocity
للكرة، يتطلب الأمر مزيدًا من التعقيد. الهدف هو تحريك الكرة أسفل الشاشة في اتجاه عشوائي بسرعة معقولة. يؤدي استدعاء طريقة normalized
إلى إنشاء كائن Vector2
تم ضبطه على الاتجاه نفسه مثل Vector2
الأصلي، ولكن تم تقليله إلى مسافة 1. يحافظ هذا على سرعة الكرة ثابتة بغض النظر عن اتجاه الكرة. يتم بعد ذلك زيادة سرعة الكرة لتكون 1/4 ارتفاع اللعبة.
يتضمن الحصول على هذه القيم المختلفة بشكل صحيح بعض التكرارات، والمعروفة أيضًا باسم اختبار اللعب في الصناعة.
يؤدي السطر الأخير إلى تشغيل عرض تصحيح الأخطاء، ما يضيف معلومات إضافية إلى الشاشة للمساعدة في تصحيح الأخطاء.
عند تشغيل اللعبة الآن، من المفترض أن يشبه الشاشة التالية.
يتضمّن كل من المكوِّن PlayArea
والمكوِّن Ball
معلومات تصحيح الأخطاء، إلا أن خلفيات الشاشة غير لامعة تؤدي إلى اقتصاص أرقام PlayArea
. وسبب ظهور معلومات تصحيح الأخطاء في كل العناصر هو تفعيل debugMode
لشجرة المكوّنات بالكامل. يمكنك أيضًا تفعيل تصحيح الأخطاء للمكوّنات المحددة فقط، إذا كان ذلك أكثر فائدة.
فإذا أعدت تشغيل اللعبة عدة مرات، فقد تلاحظ أن الكرة لا تتفاعل مع الجدران بالشكل المتوقع. لتحقيق هذا التأثير، تحتاج إلى إضافة ميزة كشف الاصطدام، وهو ما ستفعله في الخطوة التالية.
6- ارتداد
إضافة ميزة "رصد الاصطدام"
تضيف ميزة "رصد الاصطدام" سلوكًا تعرِّف فيه لعبتك عندما يلامس كائنان بعضهما البعض.
لإضافة ميزة "رصد الاصطدام" إلى اللعبة، أضِف تشكيلة "HasCollisionDetection
" إلى اللعبة BrickBreaker
كما هو موضّح في الرمز التالي.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame with HasCollisionDetection { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(Ball(
radius: ballRadius,
position: size / 2,
velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
.normalized()
..scale(height / 4)));
debugMode = true;
}
}
يتتبّع هذا المسار مجموعات المؤلِّفات المختلفة ويؤدي إلى حدوث استدعاءات في كل لعبة.
لبدء تعبئة عناصر التحكّم في اللعبة، عدِّل المكوِّن PlayArea
كما هو موضّح أدناه.
lib/src/components/play_area.dart
import 'dart:async';
import 'package:flame/collisions.dart'; // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
PlayArea()
: super(
paint: Paint()..color = const Color(0xfff2e8cf),
children: [RectangleHitbox()], // Add this parameter
);
@override
FutureOr<void> onLoad() async {
super.onLoad();
size = Vector2(game.width, game.height);
}
}
وستؤدي إضافة عنصر RectangleHitbox
كعنصر ثانوي إلى RectangleComponent
إلى إنشاء مربع نتائج لكشف الاصطدام يطابق حجم العنصر الرئيسي. هناك أداة إنشاء مصنع لـ RectangleHitbox
تسمى relative
للأوقات التي تريد فيها مربع نتيجة أصغر أو أكبر من المكون الرئيسي.
اقفز الكرة
حتى الآن، لم تُحدث إضافة ميزة "رصد الاصطدام" أي تأثير في أسلوب اللعب. ولا تتغير بعد تعديل المكوِّن Ball
. إنّ سلوك الكرة يجب أن يتغيّر عندما تصطدم بـ PlayArea
.
عدِّل المكوِّن Ball
على النحو التالي.
lib/src/components/ball.dart
import 'package:flame/collisions.dart'; // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart'; // And this import
import 'play_area.dart'; // And this one too
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> { // Add these mixins
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()]); // Add this parameter
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override // Add from here...
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
removeFromParent();
}
} else {
debugPrint('collision with $other');
}
} // To here.
}
يجري هذا المثال تغييرًا كبيرًا عند إضافة معاودة الاتصال onCollisionStart
. ويستدعي نظام رصد الحوادث المضاف إلى BrickBreaker
في المثال السابق معاودة الاتصال هذه.
أولاً، يختبر الرمز ما إذا كان هناك تعارض في Ball
مع PlayArea
. وهذا يبدو متكرّرًا في الوقت الحالي لأنّه لا تتوفّر مكوّنات أخرى في عالم الألعاب. سيتغير ذلك في الخطوة التالية، عندما تضيف خفاشًا إلى العالم. بعد ذلك، يضيف أيضًا شرط else
للتعامل معه عندما تصطدم الكرة بأشياء ليست بالمضرب. هذا تذكير لطيف لتنفيذ المنطق المتبقي، إذا فعلت ذلك.
عندما تصطدم الكرة بالجدار السفلي، تختفي فقط من سطح اللعب بينما لا تزال ظاهرة للغاية. سيتعامل مع هذا العنصر في خطوة مستقبلية، باستخدام قوة Flame's Effects.
بعد أن اصطدمت الكرة بجدران اللعبة، سيكون من المفيد منح اللاعب مضربًا لضرب الكرة...
7. اضرب الكرة
اربح الخفافيش
لإضافة مضرب لإبقاء الكرة في اللعب داخل اللعبة،
- أدرِج بعض الثوابت في ملف
lib/src/config.dart
على النحو التالي.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2; // Add from here...
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05; // To here.
يحتاج الثوابت batHeight
وbatWidth
إلى التوضيح بذاته. من ناحية أخرى، يحتاج ثابت batStep
إلى مزيد من التفسير. للتفاعل مع الكرة في هذه اللعبة، يمكن للّاعب سحب المضرب باستخدام الماوس أو الإصبع، حسب المنصة، أو استخدام لوحة المفاتيح. يحدّد ثابت batStep
مسافة خطوة الخفاش لكل ضغطة سهم لليسار أو لليمين.
- عرِّف فئة المكوِّن
Bat
على النحو التالي.
lib/src/components/bat.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class Bat extends PositionComponent
with DragCallbacks, HasGameReference<BrickBreaker> {
Bat({
required this.cornerRadius,
required super.position,
required super.size,
}) : super(
anchor: Anchor.center,
children: [RectangleHitbox()],
);
final Radius cornerRadius;
final _paint = Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill;
@override
void render(Canvas canvas) {
super.render(canvas);
canvas.drawRRect(
RRect.fromRectAndRadius(
Offset.zero & size.toSize(),
cornerRadius,
),
_paint);
}
@override
void onDragUpdate(DragUpdateEvent event) {
super.onDragUpdate(event);
position.x = (position.x + event.localDelta.x).clamp(0, game.width);
}
void moveBy(double dx) {
add(MoveToEffect(
Vector2((position.x + dx).clamp(0, game.width), position.y),
EffectController(duration: 0.1),
));
}
}
يقدم هذا المكون بعض الإمكانات الجديدة.
أولاً، مكوِّن "خفاش" هو PositionComponent
، وليس RectangleComponent
أو CircleComponent
. يعني هذا أنّ هذا الرمز يحتاج إلى عرض Bat
على الشاشة. لتنفيذ هذا الإجراء، يتم إلغاء طلب معاودة الاتصال "render
".
وعند النظر عن كثب إلى مكالمة canvas.drawRRect
(رسم مستطيل مستدير)، وقد تسأل نفسك، "أين المستطيل؟" يستفيد Offset.zero & size.toSize()
من حِمل operator &
الزائد على الفئة Offset
dart:ui
التي تنشئ Rect
. قد يؤدّي هذا الاختصار إلى إرباكك في البداية، ولكنّه ستراه بشكلٍ متكرّر عند استخدام رمزَي Flutter وFlutter ضمن المستوى الأدنى.
ثانيًا، يمكن سحب مكوّن Bat
هذا باستخدام الإصبع أو الماوس حسب النظام الأساسي. لتنفيذ هذه الوظيفة، يمكنك إضافة تشكيلة "DragCallbacks
" وإلغاء الحدث onDragUpdate
.
وأخيرًا، يحتاج المكوِّن Bat
للاستجابة للتحكّم بلوحة المفاتيح. تتيح الوظيفة moveBy
لرموز برمجية أخرى توجيه هذا المضرب إلى اليمين أو اليسار بمقدار عدد معيّن من وحدات البكسل الافتراضية. تقدّم هذه الدالة ميزة جديدة في محرّك ألعاب Flame: Effect
s. من خلال إضافة الكائن MoveToEffect
كعنصر ثانوي لهذا المكوِّن، يرى اللاعب المضرب متحركًا في موضع جديد. تتوفّر مجموعة من Effect
في تطبيق Flame لتنفيذ مجموعة متنوّعة من التأثيرات.
تتضمن وسيطات الدالة الإنشائية للتأثير إشارة إلى دالة getter، game
. ولهذا السبب، يتم تضمين تشكيلة "HasGameReference
" في هذا الصف. تضيف هذه المزيج موصِّلاً آمنًا من نوع game
إلى هذا المكوِّن للوصول إلى المثيل BrickBreaker
في أعلى شجرة المكوّنات.
- لإتاحة
Bat
لـBrickBreaker
، عدِّل ملفlib/src/components/components.dart
على النحو التالي.
lib/src/components/components.dart
export 'ball.dart';
export 'bat.dart'; // Add this export
export 'play_area.dart';
واصِل إضافة الخفاش إلى العالم
لإضافة المكوِّن Bat
إلى عالم اللعبة، يجب تحديث BrickBreaker
على النحو التالي.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart'; // Add this import
import 'package:flame/game.dart';
import 'package:flutter/material.dart'; // And this import
import 'package:flutter/services.dart'; // And this
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(Ball(
radius: ballRadius,
position: size / 2,
velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
.normalized()
..scale(height / 4)));
world.add(Bat( // Add from here...
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95))); // To here
debugMode = true;
}
@override // Add from here...
KeyEventResult onKeyEvent(
KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
}
return KeyEventResult.handled;
} // To here
}
تؤدي إضافة مزيج KeyboardEvents
والطريقة onKeyEvent
التي تم تجاوزها إلى معالجة إدخال لوحة المفاتيح. تذكر التعليمة البرمجية التي أضفتها سابقًا لتحريك المضرب بمقدار الخطوة المناسب.
يضيف الجزء المتبقي من الرمز البرمجي الضرب إلى عالم اللعبة في المكان المناسب والنسب الصحيحة. إنّ إظهار جميع هذه الإعدادات في هذا الملف يبسّط قدرتك على تعديل الحجم النسبي للمضرب والكرة لكي تحظى باللعبة بالشكل المطلوب.
إذا كنت تلعب اللعبة في هذه المرحلة، ستلاحظ إمكانية تحريك المضرب لاعتراض الكرة، ولكن لا يمكنك تلقّي أي استجابة مرئية، باستثناء سجلّ تصحيح الأخطاء الذي أضفته في رمز رصد الاصطدامات في جهاز "Ball
".
حان الوقت لإصلاح ذلك الآن. عدِّل المكوِّن Ball
على النحو التالي.
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart'; // Add this import
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart'; // And this import
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()]);
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(RemoveEffect( // Modify from here...
delay: 0.35,
));
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x = velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else { // To here.
debugPrint('collision with $other');
}
}
}
تؤدي هذه التغييرات على الرمز إلى إصلاح مشكلتين منفصلتين.
أولاً، يقوم بإصلاح الكرة خارج الوجود بمجرد لمسها أسفل الشاشة. لحلّ هذه المشكلة، يمكنك استبدال استدعاء removeFromParent
بـ RemoveEffect
. يزيل RemoveEffect
الكرة من عالم اللعبة بعد السماح للكرة بالخروج من منطقة اللعب القابلة للعرض.
ثانيًا، تؤدي هذه التغييرات إلى إصلاح طريقة التعامل مع التصادم بين المضرب والكرة. يعمل رمز المناولة هذا إلى حد كبير لصالح اللاعب. طالما لمس اللاعب الكرة بالمضرب، تعود الكرة إلى أعلى الشاشة. إذا كنت تريد استخدام أسلوب لعب أكثر واقعيةً، يمكنك تغيير أسلوب اللعب لتناسب شعورك تجاه اللعبة.
تجدر الإشارة إلى مدى تعقيد تعديل velocity
. فهي لا تعكس فقط العنصر y
للسرعة، كما هي الحال مع حوادث تصادم الجدران. ويتم أيضًا تعديل المكوِّن x
بطريقة تعتمد على الموضع النسبي للمضرب والكرة في وقت التلامس. تمنح هذه اللعبة اللاعب قدرًا أكبر من التحكم في ما تفعله الكرة، ولكن لا يتم إعلام اللاعب بأي طريقة إلا من خلال اللعب.
والآن بعد أن أصبح لديك مضرب يمكنك ضربه بالكرة، سيكون من الرائع أن تحطم بعض الطوب لكسرها بالكرة!
8. تحطيم الحائط
صناعة الطوب
لإضافة طوب إلى اللعبة،
- أدرِج بعض الثوابت في ملف
lib/src/config.dart
على النحو التالي.
lib/src/config.dart
import 'package:flutter/material.dart'; // Add this import
const brickColors = [ // Add this const
Color(0xfff94144),
Color(0xfff3722c),
Color(0xfff8961e),
Color(0xfff9844a),
Color(0xfff9c74f),
Color(0xff90be6d),
Color(0xff43aa8b),
Color(0xff4d908e),
Color(0xff277da1),
Color(0xff577590),
];
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;
const brickGutter = gameWidth * 0.015; // Add from here...
final brickWidth =
(gameWidth - (brickGutter * (brickColors.length + 1)))
/ brickColors.length;
const brickHeight = gameHeight * 0.03;
const difficultyModifier = 1.03; // To here.
- أدرِج المكوِّن
Brick
على النحو التالي.
lib/src/components/brick.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';
class Brick extends RectangleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Brick({required super.position, required Color color})
: super(
size: Vector2(brickWidth, brickHeight),
anchor: Anchor.center,
paint: Paint()
..color = color
..style = PaintingStyle.fill,
children: [RectangleHitbox()],
);
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
removeFromParent();
if (game.world.children.query<Brick>().length == 1) {
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
الآن، يُفترض أن تكون معظم هذه التعليمة البرمجية مألوفة. يستخدم هذا الرمز RectangleComponent
، مع كشف الاصطدام ومرجع آمن من النوع للعبة BrickBreaker
أعلى شجرة المكوّنات.
أهم مفهوم جديد تقدمه هذه التعليمة البرمجية هو كيفية تحقيق اللاعب شرط الفوز. يتحقق شرط الفوز من خلال الاستعلام عن العالم عن الطوب، ويؤكد أن واحدًا فقط منها لا يزال باقيًا. قد يكون هذا محيرًا بعض الشيء، لأن السطر السابق يزيل هذا الطوب من الأصل.
النقطة الأساسية التي يجب فهمها هي أن إزالة المكون هي أمر في قائمة الانتظار. وهو يزيل الهدف بعد تنفيذ هذا الرمز، ولكن قبل التخطّي التالي في عالم اللعبة.
لإتاحة الوصول إلى المكوِّن Brick
من خلال BrickBreaker
، عدِّل lib/src/components/components.dart
على النحو التالي.
lib/src/components/components.dart
export 'ball.dart';
export 'bat.dart';
export 'brick.dart'; // Add this export
export 'play_area.dart';
أضِف الطوب إلى العالم
عدِّل المكوِّن Ball
على النحو التالي.
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart'; // Add this import
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
required this.difficultyModifier, // Add this parameter
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()]);
final Vector2 velocity;
final double difficultyModifier; // Add this member
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(RemoveEffect(
delay: 0.35,
));
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x = velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else if (other is Brick) { // Modify from here...
if (position.y < other.position.y - other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.y > other.position.y + other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.x < other.position.x) {
velocity.x = -velocity.x;
} else if (position.x > other.position.x) {
velocity.x = -velocity.x;
}
velocity.setFrom(velocity * difficultyModifier); // To here.
}
}
}
يقدم هذا الجانب الجديد الوحيد، وهو معدِّل مستوى الصعوبة يزيد من سرعة الكرة بعد كل اصطدام طوب. يجب اختبار معلمة التوليف هذه للعثور على منحنى الصعوبة المناسب المناسب للعبتك.
عدِّل لعبة "BrickBreaker
" على النحو التالي.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(Ball(
difficultyModifier: difficultyModifier, // Add this argument
radius: ballRadius,
position: size / 2,
velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
.normalized()
..scale(height / 4)));
world.add(Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95)));
await world.addAll([ // Add from here...
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]); // To here.
debugMode = true;
}
@override
KeyEventResult onKeyEvent(
KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
}
return KeyEventResult.handled;
}
}
أما إذا شغّلت اللعبة كما هي حاليًا، فستعرض جميع آليات اللعبة. يمكنك إيقاف تصحيح الأخطاء وجعله قد تم، ولكن يبدو أن هناك شيئًا ما مفقود.
هل يمكنك عرض شاشة ترحيب وعرض مباراة على الشاشة أو عرض نتيجة؟ يمكن أن يضيف Flutter هذه الميزات إلى اللعبة، ما يجعلك تلفت انتباهك في الخطوة التالية.
9. الفوز في اللعبة
إضافة حالات التشغيل
في هذه الخطوة، يمكنك تضمين لعبة Flame في برنامج تضمين Flutter، ثم إضافة عناصر مركّبة على Flutter لشاشات الترحيب وانتهاء اللعبة وشاشات الفوز.
أولاً، عليك تعديل ملفات الألعاب والمكوّنات لتنفيذ حالة تشغيل تعكس ما إذا كان سيتم عرض تراكب أم لا، وأي عنصر تراكب في هذه الحالة.
- عدِّل لعبة "
BrickBreaker
" على النحو التالي.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'components/components.dart';
import 'config.dart';
enum PlayState { welcome, playing, gameOver, won } // Add this enumeration
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents, TapDetector { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
late PlayState _playState; // Add from here...
PlayState get playState => _playState;
set playState(PlayState playState) {
_playState = playState;
switch (playState) {
case PlayState.welcome:
case PlayState.gameOver:
case PlayState.won:
overlays.add(playState.name);
case PlayState.playing:
overlays.remove(PlayState.welcome.name);
overlays.remove(PlayState.gameOver.name);
overlays.remove(PlayState.won.name);
}
} // To here.
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
playState = PlayState.welcome; // Add from here...
}
void startGame() {
if (playState == PlayState.playing) return;
world.removeAll(world.children.query<Ball>());
world.removeAll(world.children.query<Bat>());
world.removeAll(world.children.query<Brick>());
playState = PlayState.playing; // To here.
world.add(Ball(
difficultyModifier: difficultyModifier,
radius: ballRadius,
position: size / 2,
velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
.normalized()
..scale(height / 4)));
world.add(Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95)));
world.addAll([ // Drop the await
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]);
} // Drop the debugMode
@override // Add from here...
void onTap() {
super.onTap();
startGame();
} // To here.
@override
KeyEventResult onKeyEvent(
KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
case LogicalKeyboardKey.space: // Add from here...
case LogicalKeyboardKey.enter:
startGame(); // To here.
}
return KeyEventResult.handled;
}
@override
Color backgroundColor() => const Color(0xfff2e8cf); // Add this override
}
من خلال هذا الرمز، تغيّرت لعبة BrickBreaker
بشكل كبير. تستغرق إضافة التعداد playState
الكثير من العمل. يشير هذا إلى الموضع الذي يدخل فيه اللاعب إلى اللعبة أو يلعبها أو يخسرها أو يفوز بها. في أعلى الملف، يمكنك تحديد التعداد، ثم إنشاء مثيل له كحالة مخفية باستخدام قيم get وsets مطابقة. تتيح هذه الأدوات والإعدادات إمكانية تعديل العناصر المركّبة عندما يتم تبديل حالة اللعب في أجزاء مختلفة من اللعبة.
بعد ذلك، قسَّمت الرمز في onLoad
إلى onLoad وطريقة startGame
جديدة. قبل هذا التغيير، كان بإمكانك بدء لعبة جديدة فقط من خلال إعادة تشغيل اللعبة. بفضل هذه الإضافات الجديدة، يمكن للّاعب الآن بدء لعبة جديدة بدون اتخاذ إجراءات صارمة.
للسماح للّاعب ببدء لعبة جديدة، تكون قد أعددت معالجَين جديدَين للعبة. لقد أضفت معالج نقر ووسّعت معالج لوحة المفاتيح لتمكين المستخدم من بدء لعبة جديدة بعدة وسائط. أمّا في ما يتعلّق بحالة اللعب، فمن المنطقي تعديل المكوّنات لتشغيل انتقالات حالة اللعب عندما يفوز اللاعب أو يخسر.
- عدِّل المكوِّن
Ball
على النحو التالي.
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart';
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
required this.difficultyModifier,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()]);
final Vector2 velocity;
final double difficultyModifier;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(RemoveEffect(
delay: 0.35,
onComplete: () { // Modify from here
game.playState = PlayState.gameOver;
})); // To here.
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x = velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else if (other is Brick) {
if (position.y < other.position.y - other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.y > other.position.y + other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.x < other.position.x) {
velocity.x = -velocity.x;
} else if (position.x > other.position.x) {
velocity.x = -velocity.x;
}
velocity.setFrom(velocity * difficultyModifier);
}
}
}
يؤدي هذا التغيير البسيط إلى إضافة استدعاء onComplete
إلى RemoveEffect
، ما يؤدي إلى تفعيل حالة تشغيل gameOver
. يجب أن يكون ذلك مناسبًا إذا سمح اللاعب للكرة بالهروب من أسفل الشاشة.
- عدِّل المكوِّن
Brick
على النحو التالي.
lib/src/components/brick.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';
class Brick extends RectangleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Brick({required super.position, required Color color})
: super(
size: Vector2(brickWidth, brickHeight),
anchor: Anchor.center,
paint: Paint()
..color = color
..style = PaintingStyle.fill,
children: [RectangleHitbox()],
);
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
removeFromParent();
if (game.world.children.query<Brick>().length == 1) {
game.playState = PlayState.won; // Add this line
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
من ناحية أخرى، إذا تمكّن اللاعب من كسر كل الطوب، سيحصل على "اللعبة الفائزة". الشاشة. أحسنت يا لاعب، أحسنت!
إضافة برنامج تضمين Flutter
لتوفير مكان لتضمين اللعبة وإضافة عناصر مركّبة في حالة التشغيل، يمكنك إضافة واجهة مستخدم Flutter.
- إنشاء دليل
widgets
ضمنlib/src
- أضِف ملف
game_app.dart
وأدرِج المحتوى التالي فيه.
lib/src/widgets/game_app.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../brick_breaker.dart';
import '../config.dart';
class GameApp extends StatelessWidget {
const GameApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
textTheme: GoogleFonts.pressStart2pTextTheme().apply(
bodyColor: const Color(0xff184e77),
displayColor: const Color(0xff184e77),
),
),
home: Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xffa9d6e5),
Color(0xfff2e8cf),
],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: FittedBox(
child: SizedBox(
width: gameWidth,
height: gameHeight,
child: GameWidget.controlled(
gameFactory: BrickBreaker.new,
overlayBuilderMap: {
PlayState.welcome.name: (context, game) => Center(
child: Text(
'TAP TO PLAY',
style:
Theme.of(context).textTheme.headlineLarge,
),
),
PlayState.gameOver.name: (context, game) => Center(
child: Text(
'G A M E O V E R',
style:
Theme.of(context).textTheme.headlineLarge,
),
),
PlayState.won.name: (context, game) => Center(
child: Text(
'Y O U W O N ! ! !',
style:
Theme.of(context).textTheme.headlineLarge,
),
),
},
),
),
),
),
),
),
),
),
);
}
}
يتّبع معظم المحتوى في هذا الملف تصميم شجرة عادي لتطبيق Flutter المصغّر. تتضمّن الأجزاء الخاصة بـ Flame استخدام GameWidget.controlled
لإنشاء مثيل لعبة BrickBreaker
وإدارته والوسيطة overlayBuilderMap
الجديدة إلى GameWidget
.
يجب أن تتم محاذاة مفاتيح overlayBuilderMap
هذه مع التراكبات التي أضافتها أداة playState
في BrickBreaker
أو أزلتها. محاولة تكوين تراكب ليس في هذه الخريطة تؤدي إلى وجوه غير سعيدة في كل مكان.
- لعرض هذه الوظيفة الجديدة على الشاشة، يمكنك استبدال ملف واحد (
lib/main.dart
) بالمحتوى التالي.
lib/main.dart
import 'package:flutter/material.dart';
import 'src/widgets/game_app.dart';
void main() {
runApp(const GameApp());
}
عند تشغيل هذا الرمز على نظام التشغيل iOS أو Linux أو Windows أو الويب، يتم عرض الناتج المقصود في اللعبة. إذا كنت تستهدف نظام التشغيل macOS أو Android، عليك إجراء تعديل أخير لتفعيل ميزة عرض المحتوى على "google_fonts
".
تمكين الوصول إلى الخط
إضافة إذن الوصول إلى الإنترنت لنظام التشغيل Android
في حال استخدام نظام التشغيل Android، يجب إضافة إذن الوصول إلى الإنترنت. يمكنك تعديل "AndroidManifest.xml
" على النحو التالي.
android/app/src/main/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Add the following line -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="brick_breaker"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
تعديل ملفات الاستحقاق على نظام التشغيل macOS
على نظام التشغيل macOS، لديك ملفان لتعديلهما.
- عدِّل ملف
DebugProfile.entitlements
ليتطابق مع الرمز التالي.
macos/Runner/DebugProfile.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<!-- Add from here... -->
<key>com.apple.security.network.client</key>
<true/>
<!-- to here. -->
</dict>
</plist>
- عدِّل ملف
Release.entitlements
ليتطابق مع الرمز التالي.
macos/Runner/Release.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- Add from here... -->
<key>com.apple.security.network.client</key>
<true/>
<!-- to here. -->
</dict>
</plist>
عند تشغيل هذه العملية بالطريقة نفسها، يتم عرض شاشة ترحيب وشاشة تظهر عند انتهاء المباراة أو الفوز بها على جميع الأنظمة الأساسية. قد تكون هذه الشاشات مبسطة بعض الشيء وسيكون من الجيد أن يكون لديك نتيجة. لذا، خمن ما ستقوم به في الخطوة التالية!
10. الاحتفاظ بالنتيجة
إضافة النتيجة إلى اللعبة
في هذه الخطوة، يمكنك عرض نتيجة اللعبة على سياق Flutter المحيط. في هذه الخطوة، سيتم عرض الحالة من لعبة Flame إلى إدارة حالة Flutter المحيطة. يتيح ذلك لرمز اللعبة تعديل النتيجة في كل مرة يكسر فيها اللاعب لوح اللعب.
- عدِّل لعبة "
BrickBreaker
" على النحو التالي.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'components/components.dart';
import 'config.dart';
enum PlayState { welcome, playing, gameOver, won }
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents, TapDetector {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final ValueNotifier<int> score = ValueNotifier(0); // Add this line
final rand = math.Random();
double get width => size.x;
double get height => size.y;
late PlayState _playState;
PlayState get playState => _playState;
set playState(PlayState playState) {
_playState = playState;
switch (playState) {
case PlayState.welcome:
case PlayState.gameOver:
case PlayState.won:
overlays.add(playState.name);
case PlayState.playing:
overlays.remove(PlayState.welcome.name);
overlays.remove(PlayState.gameOver.name);
overlays.remove(PlayState.won.name);
}
}
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
playState = PlayState.welcome;
}
void startGame() {
if (playState == PlayState.playing) return;
world.removeAll(world.children.query<Ball>());
world.removeAll(world.children.query<Bat>());
world.removeAll(world.children.query<Brick>());
playState = PlayState.playing;
score.value = 0; // Add this line
world.add(Ball(
difficultyModifier: difficultyModifier,
radius: ballRadius,
position: size / 2,
velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
.normalized()
..scale(height / 4)));
world.add(Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95)));
world.addAll([
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]);
}
@override
void onTap() {
super.onTap();
startGame();
}
@override
KeyEventResult onKeyEvent(
KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
case LogicalKeyboardKey.space:
case LogicalKeyboardKey.enter:
startGame();
}
return KeyEventResult.handled;
}
@override
Color backgroundColor() => const Color(0xfff2e8cf);
}
تعني إضافة score
إلى اللعبة أنّك تربط حالة اللعبة بإدارة حالة Flutter.
- عدِّل الفئة
Brick
لإضافة نقطة إلى النتيجة عندما يكسر اللاعب الطوب.
lib/src/components/brick.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';
class Brick extends RectangleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Brick({required super.position, required Color color})
: super(
size: Vector2(brickWidth, brickHeight),
anchor: Anchor.center,
paint: Paint()
..color = color
..style = PaintingStyle.fill,
children: [RectangleHitbox()],
);
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
removeFromParent();
game.score.value++; // Add this line
if (game.world.children.query<Brick>().length == 1) {
game.playState = PlayState.won;
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
أنشئ لعبة بمظهر رائع
بعد أن احتفظت بالنتائج في Flutter، حان الوقت لتجميع عناصر واجهة مستخدم عليها لتحسين مظهرها.
- أنشِئ
score_card.dart
في "lib/src/widgets
" وأضِف ما يلي.
lib/src/widgets/score_card.dart
import 'package:flutter/material.dart';
class ScoreCard extends StatelessWidget {
const ScoreCard({
super.key,
required this.score,
});
final ValueNotifier<int> score;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int>(
valueListenable: score,
builder: (context, score, child) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 12, 18),
child: Text(
'Score: $score'.toUpperCase(),
style: Theme.of(context).textTheme.titleLarge!,
),
);
},
);
}
}
- يمكنك إنشاء
overlay_screen.dart
فيlib/src/widgets
وإضافة الرمز التالي.
تساعد هذه الميزة في إضفاء لمسة مميّزة على الإعلانات المركّبة باستخدام فعالية حزمة flutter_animate
لإضافة المزيد من الحركة والنمط إلى شاشات التراكب.
lib/src/widgets/overlay_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
class OverlayScreen extends StatelessWidget {
const OverlayScreen({
super.key,
required this.title,
required this.subtitle,
});
final String title;
final String subtitle;
@override
Widget build(BuildContext context) {
return Container(
alignment: const Alignment(0, -0.15),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: Theme.of(context).textTheme.headlineLarge,
).animate().slideY(duration: 750.ms, begin: -3, end: 0),
const SizedBox(height: 16),
Text(
subtitle,
style: Theme.of(context).textTheme.headlineSmall,
)
.animate(onPlay: (controller) => controller.repeat())
.fadeIn(duration: 1.seconds)
.then()
.fadeOut(duration: 1.seconds),
],
),
);
}
}
للحصول على معلومات أكثر تفصيلاً حول إمكانيات flutter_animate
، يمكنك الاطّلاع على الدرس التطبيقي حول الترميز إنشاء واجهات مستخدم من الجيل التالي في Flutter.
تغيّر هذا الرمز كثيرًا في المكوِّن GameApp
. أولاً، لتفعيل وصول ScoreCard
إلى score
، عليك تحويله من StatelessWidget
إلى StatefulWidget
. تتطلب إضافة بطاقة النتائج إضافة "Column
" ليتم تجميع النتيجة فوق اللعبة.
ثانيًا، لتحسين تجربتَي الترحيب واللعب وربح النقاط، أضفت تطبيق OverlayScreen
المصغّر الجديد.
lib/src/widgets/game_app.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'overlay_screen.dart'; // Add this import
import 'score_card.dart'; // And this one too
class GameApp extends StatefulWidget { // Modify this line
const GameApp({super.key});
@override // Add from here...
State<GameApp> createState() => _GameAppState();
}
class _GameAppState extends State<GameApp> {
late final BrickBreaker game;
@override
void initState() {
super.initState();
game = BrickBreaker();
} // To here.
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
textTheme: GoogleFonts.pressStart2pTextTheme().apply(
bodyColor: const Color(0xff184e77),
displayColor: const Color(0xff184e77),
),
),
home: Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xffa9d6e5),
Color(0xfff2e8cf),
],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Column( // Modify from here...
children: [
ScoreCard(score: game.score),
Expanded(
child: FittedBox(
child: SizedBox(
width: gameWidth,
height: gameHeight,
child: GameWidget(
game: game,
overlayBuilderMap: {
PlayState.welcome.name: (context, game) =>
const OverlayScreen(
title: 'TAP TO PLAY',
subtitle: 'Use arrow keys or swipe',
),
PlayState.gameOver.name: (context, game) =>
const OverlayScreen(
title: 'G A M E O V E R',
subtitle: 'Tap to Play Again',
),
PlayState.won.name: (context, game) =>
const OverlayScreen(
title: 'Y O U W O N ! ! !',
subtitle: 'Tap to Play Again',
),
},
),
),
),
),
],
), // To here.
),
),
),
),
),
);
}
}
بعد تنفيذ كل هذه الخطوات، من المفترض أن تتمكّن الآن من تشغيل هذه اللعبة على أيّ من منصات Flutter الست المستهدَفة. يجب أن تكون اللعبة مشابهة لما يلي.
11. تهانينا
تهانينا، لقد نجحت في إنشاء لعبة باستخدام Flutter وFlutter
صمّمت لعبة باستخدام محرّك الألعاب Flame الثنائي الأبعاد وضمّنتها في برنامج تضمين Flutter. لقد استخدمت تأثيرات Flame لتحريك المكوّنات وإزالتها. لقد استخدمت حزمتَي Google Fonts وFlutter Animate لإنشاء اللعبة بالكامل.
ما هي الخطوات التالية؟
اطّلع على بعض هذه الدروس التطبيقية حول الترميز...
- إنشاء الجيل التالي من واجهات المستخدم في Flutter
- تحويل تطبيق Flutter إلى مظهر مملّ إلى رائع
- إضافة عمليات الشراء داخل التطبيق إلى تطبيق Flutter